diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000..7457447b5c --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,21 @@ +self-hosted-runner: + # Labels of self-hosted runner in array of strings. + labels: + - macOS + - packrat-e2e + +# Configuration variables in array of strings defined in your repository or +# organization. `null` means disabling configuration variables check. +# Empty array means no configuration variable is allowed. +config-variables: null + +# Configuration for file paths. The keys are glob patterns to match to file +# paths relative to the repository root. The values are the configurations for +# the file paths. Note that the path separator is always '/'. +# The following configurations are available. +# +# "ignore" is an array of regular expression patterns. Matched error messages +# are ignored. This is similar to the "-ignore" command line option. +paths: +# .github/workflows/**/*.yml: +# ignore: [] diff --git a/.github/workflows/swift-e2e.yml b/.github/workflows/swift-e2e.yml new file mode 100644 index 0000000000..f47e7e5e2b --- /dev/null +++ b/.github/workflows/swift-e2e.yml @@ -0,0 +1,245 @@ +name: Swift E2E Tests + +on: + pull_request: + branches: ["**"] + paths: + - "apps/swift/**" + - "packages/api/src/**" + - "packages/api/drizzle/**" + - "packages/api/package.json" + - "package.json" + - "bun.lock" + - ".github/workflows/swift-e2e.yml" + push: + branches: [main, development] + paths: + - "apps/swift/**" + - "packages/api/src/**" + - "packages/api/drizzle/**" + - "packages/api/package.json" + - "package.json" + - "bun.lock" + - ".github/workflows/swift-e2e.yml" + schedule: + - cron: "17 8 * * *" + workflow_dispatch: + inputs: + run_macos_ui: + description: "Run the full macOS UI suite on a self-hosted Mac runner" + required: false + type: boolean + default: true + run_ios_ui: + description: "Run the exploratory Swift iOS UI suite on a GitHub-hosted macOS runner" + required: false + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + XCODE_VERSION: "26.2" + E2E_API_BASE_URL: ${{ secrets.SWIFT_E2E_API_BASE_URL }} + E2E_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + E2E_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + E2E_SCREENSHOT_DIR: ${{ github.workspace }}/apps/swift/TestResults/screenshots + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + +jobs: + macos-ui: + name: macOS Swift UI E2E + runs-on: [self-hosted, macOS, packrat-e2e] + timeout-minutes: 45 + if: > + github.event_name == 'schedule' || + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.run_macos_ui) + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + cache: true + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Verify Swift E2E secrets + run: | + missing=() + [ -z "${E2E_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") + [ -z "${E2E_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") + [ -z "${E2E_API_BASE_URL:-}" ] && missing+=("SWIFT_E2E_API_BASE_URL") + [ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Required Swift E2E secrets missing: ${missing[*]}" + exit 1 + fi + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + + - name: Check Automation Mode status + run: | + automationmodetool status || true + + - name: Generate Swift Xcode project + run: bun run swift + + - name: Seed E2E test user + run: bun run --filter @packrat/api db:seed:e2e-user + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + E2E_TEST_EMAIL: ${{ env.E2E_EMAIL }} + E2E_TEST_PASSWORD: ${{ env.E2E_PASSWORD }} + + - name: Run macOS Swift UI E2E + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + caffeinate -dimsu bun run e2e:swift:mac-smoke + else + caffeinate -dimsu bun run e2e:swift:mac-ui + fi + + - name: Summarize macOS xcresult + if: always() + run: | + result="$(find apps/swift/TestResults -maxdepth 1 -name '*.xcresult' -type d | sort | tail -1)" + if [ -z "$result" ]; then + echo "No xcresult bundle found." + exit 0 + fi + echo "### macOS Swift UI E2E" >> "$GITHUB_STEP_SUMMARY" + echo "\`$result\`" >> "$GITHUB_STEP_SUMMARY" + xcrun xcresulttool get test-results summary --path "$result" | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload macOS xcresult + if: always() + uses: actions/upload-artifact@v7 + with: + name: swift-macos-ui-xcresult + path: apps/swift/TestResults/*.xcresult + retention-days: 14 + + - name: Upload macOS screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: swift-macos-ui-screenshots + path: apps/swift/TestResults/screenshots/ + if-no-files-found: ignore + retention-days: 14 + + - name: Upload macOS failure triage bundle + if: failure() + uses: actions/upload-artifact@v7 + with: + name: swift-macos-ui-failure-triage + path: apps/swift/TestResults/ + if-no-files-found: ignore + retention-days: 14 + + ios-ui: + name: iOS Swift UI E2E (Exploratory) + runs-on: macos-15 + timeout-minutes: 60 + if: > + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && inputs.run_ios_ui) + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + cache: true + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Verify Swift E2E secrets + run: | + missing=() + [ -z "${E2E_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") + [ -z "${E2E_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") + [ -z "${E2E_API_BASE_URL:-}" ] && missing+=("SWIFT_E2E_API_BASE_URL") + [ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Required Swift E2E secrets missing: ${missing[*]}" + exit 1 + fi + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + + - name: Generate Swift Xcode project + run: bun run swift + + - name: Seed E2E test user + run: bun run --filter @packrat/api db:seed:e2e-user + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + E2E_TEST_EMAIL: ${{ env.E2E_EMAIL }} + E2E_TEST_PASSWORD: ${{ env.E2E_PASSWORD }} + + - name: Run iOS Swift UI E2E + run: bun run e2e:swift:ios + + - name: Summarize iOS xcresult + if: always() + run: | + result="$(find apps/swift/TestResults -maxdepth 1 -name '*.xcresult' -type d | sort | tail -1)" + if [ -z "$result" ]; then + echo "No xcresult bundle found." + exit 0 + fi + echo "### iOS Swift UI E2E" >> "$GITHUB_STEP_SUMMARY" + echo "\`$result\`" >> "$GITHUB_STEP_SUMMARY" + xcrun xcresulttool get test-results summary --path "$result" | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload iOS xcresult + if: always() + uses: actions/upload-artifact@v7 + with: + name: swift-ios-ui-xcresult + path: apps/swift/TestResults/*.xcresult + retention-days: 14 + + - name: Upload iOS screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: swift-ios-ui-screenshots + path: apps/swift/TestResults/screenshots/ + if-no-files-found: ignore + retention-days: 14 + + - name: Upload iOS failure triage bundle + if: failure() + uses: actions/upload-artifact@v7 + with: + name: swift-ios-ui-failure-triage + path: apps/swift/TestResults/ + if-no-files-found: ignore + retention-days: 14 diff --git a/apps/swift/README.md b/apps/swift/README.md new file mode 100644 index 0000000000..d6ba1bd690 --- /dev/null +++ b/apps/swift/README.md @@ -0,0 +1,105 @@ +# PackRat Swift Testing + +The generated Xcode project is not committed. Regenerate it after changing +`project.yml`: + +```sh +bun swift +``` + +If Xcode or SwiftPM reports a temporary-directory error on this machine, ensure +the configured temp directory exists: + +```sh +mkdir -p /Volumes/CrucialX10/tmp/andrewbierman +``` + +## Commands + +```sh +bun run test:swift:runner +bun run test:swift:unit +bun run e2e:swift:ios-smoke +bun run e2e:swift:ios +bun run e2e:swift:mac +bun run e2e:swift:mac-smoke +bun run e2e:swift:mac-ui +``` + +`e2e:swift` defaults to iOS UI tests for compatibility with the original +runner. All Xcode result bundles are written under `apps/swift/TestResults/`. + +Smoke modes are intentionally small PR gates: + +- `e2e:swift:mac-smoke`: macOS login, sidebar navigation, and pack create/add-item. +- `e2e:swift:ios-smoke`: iOS login, tab navigation, and pack create. + +Full modes are the platform confidence gates: + +- `e2e:swift:mac-ui`: full native macOS app UI suite. +- `e2e:swift:ios`: exploratory native Swift iOS app UI suite. This is separate + from the existing Expo iOS app, which remains covered by Maestro. + +UI modes require credentials in the process environment or `.env.local`: + +```sh +E2E_EMAIL=... +E2E_PASSWORD=... +``` + +The runner also accepts `E2E_TEST_EMAIL` and `E2E_TEST_PASSWORD`, then forwards +them to XCTest as `E2E_EMAIL` and `E2E_PASSWORD`. Credential values are not +printed by the runner. + +Set `E2E_API_BASE_URL` to point UI tests at a specific API worker without +changing the app's saved preferences: + +```sh +E2E_API_BASE_URL=http://localhost:8788 +``` + +## CI + +Swift E2E CI is defined in `.github/workflows/swift-e2e.yml`. + +- Pull requests run the macOS smoke subset on a self-hosted Mac runner. +- Pushes, scheduled runs, and manual macOS runs execute the full macOS suite. +- Swift iOS runs nightly or manually and is labeled exploratory while the Expo + app remains the production iOS app. +- Each CI run uploads `.xcresult` bundles, screenshots, failure triage artifacts, + and a GitHub step summary generated with `xcresulttool`. + +See `docs/ci/swift-e2e-runner.md` for self-hosted Mac runner setup. + +## Data Isolation + +Swift E2E tests use unique names for records they create. That keeps repeated +runs safe against shared account state, but it does not fully clean historical +test data from the backend. If the shared E2E account starts accumulating enough +data to affect performance or assertions, add API-backed cleanup helpers or a +test-only reset endpoint and call it from the runner before/after UI modes. + +## Signing + +`e2e:swift:mac` passes `CODE_SIGNING_ALLOWED=NO` so the local compile gate can +run without provisioning. + +`e2e:swift:mac-ui` must still be signed because XCTest launches a runner app, +but the runner uses Xcode's local ad-hoc identity (`Sign to Run Locally`) so +smoke tests do not block on private-key prompts. + +Normal signed builds use automatic signing with team `666HGMV2LU`. If command- +line signing fails with `errSecInternalComponent`, the certificate is installed +but `codesign` cannot access the private key from the login keychain. Unlock the +keychain and allow Apple tooling to use the key before rerunning: + +```sh +security unlock-keychain ~/Library/Keychains/login.keychain-db +security set-key-partition-list -S apple-tool:,apple: -s ~/Library/Keychains/login.keychain-db +``` + +## Worktree Hygiene + +The Swift branch is active and may move while multiple agents are working. +Fetch before editing shared Swift files, then compare against +`origin/claude/swift-mac-app-effort-tTGd7` before final verification. diff --git a/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift b/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift index 6a0c690850..35a127084d 100644 --- a/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift +++ b/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift @@ -129,12 +129,19 @@ struct ChatView: View { private var inputBar: some View { HStack(alignment: .bottom, spacing: 10) { + #if os(macOS) + TextField("Ask about gear, trips, packing...", text: $viewModel.inputText) + .textFieldStyle(.roundedBorder) + .onSubmit { viewModel.sendMessage() } + .accessibilityIdentifier("chat_input") + #else TextField("Ask about gear, trips, packing…", text: $viewModel.inputText, axis: .vertical) .textFieldStyle(.plain) .lineLimit(1...5) .padding(.vertical, 8) .onSubmit { viewModel.sendMessage() } .accessibilityIdentifier("chat_input") + #endif Group { if viewModel.isStreaming { diff --git a/apps/swift/Sources/PackRat/Features/Feed/FeedView.swift b/apps/swift/Sources/PackRat/Features/Feed/FeedView.swift index b28d7caa01..ecf01ce5e8 100644 --- a/apps/swift/Sources/PackRat/Features/Feed/FeedView.swift +++ b/apps/swift/Sources/PackRat/Features/Feed/FeedView.swift @@ -39,6 +39,7 @@ struct FeedView: View { .accessibilityIdentifier("feed_new_post_button") .disabled(!authManager.isAuthenticated) .keyboardShortcut("n", modifiers: .command) + .accessibilityIdentifier("new_post_button") } } .task { if authManager.isAuthenticated && viewModel.posts.isEmpty { await viewModel.load() } } diff --git a/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift b/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift index 1327fd60b5..f7a52f6c98 100644 --- a/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift +++ b/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift @@ -64,6 +64,7 @@ struct PacksListView: View { Button("New Pack", systemImage: "plus") { showingCreateSheet = true } .accessibilityIdentifier("packs_new_pack_button") .keyboardShortcut("n", modifiers: .command) + .accessibilityIdentifier("new_pack_button") } if viewModel.isLoading || isLoadingPublic { ProgressView().controlSize(.small) diff --git a/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift b/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift index 3094cc8747..ae73fe78d7 100644 --- a/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift +++ b/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift @@ -42,6 +42,7 @@ struct TripsListView: View { Button("Plan Trip", systemImage: "plus") { showingCreateSheet = true } .accessibilityIdentifier("trips_plan_trip_button") .keyboardShortcut("n", modifiers: [.command, .shift]) + .accessibilityIdentifier("plan_trip_button") } } .task { await viewModel.load(context: modelContext) } diff --git a/apps/swift/Sources/PackRat/Network/APIClient.swift b/apps/swift/Sources/PackRat/Network/APIClient.swift index 11b22daef6..0156cfdc70 100644 --- a/apps/swift/Sources/PackRat/Network/APIClient.swift +++ b/apps/swift/Sources/PackRat/Network/APIClient.swift @@ -26,6 +26,9 @@ actor APIClient { ] static var resolvedBaseURL: URL { + if let override = ProcessInfo.processInfo.environment["E2E_API_BASE_URL"], + !override.isEmpty, + let url = URL(string: override) { return url } if let override = UserDefaults.standard.string(forKey: "apiBaseURL"), !override.isEmpty, let url = URL(string: override) { return url } diff --git a/apps/swift/Sources/PackRat/PackRatApp.swift b/apps/swift/Sources/PackRat/PackRatApp.swift index 61b9cbee37..138a89eb5c 100644 --- a/apps/swift/Sources/PackRat/PackRatApp.swift +++ b/apps/swift/Sources/PackRat/PackRatApp.swift @@ -1,9 +1,24 @@ import SwiftUI import SwiftData +#if os(macOS) +import AppKit +#endif @main struct PackRatApp: App { @State private var authManager = AuthManager() + #if os(macOS) + @NSApplicationDelegateAdaptor(PackRatMacAppDelegate.self) private var appDelegate + #endif + + init() { + #if os(macOS) + if ProcessInfo.processInfo.arguments.contains("--reset-auth") { + UserDefaults.standard.set(true, forKey: "ApplePersistenceIgnoreState") + UserDefaults.standard.set(false, forKey: "NSQuitAlwaysKeepsWindows") + } + #endif + } init() { // Telemetry has to start before any view is mounted so launch-time @@ -51,3 +66,27 @@ struct PackRatApp: App { #endif } } + +#if os(macOS) +final class PackRatMacAppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + guard ProcessInfo.processInfo.arguments.contains("--reset-auth") else { return } + + NSApp.setActivationPolicy(.regular) + DispatchQueue.main.async { + NSApp.unhide(nil) + NSApp.activate(ignoringOtherApps: true) + if NSApp.windows.isEmpty { + NSApp.sendAction(Selector(("newWindow:")), to: nil, from: nil) + } + } + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + NSApp.sendAction(Selector(("newWindow:")), to: nil, from: nil) + } + return true + } +} +#endif diff --git a/apps/swift/Tests/PackRatMacUITests/MacHomeFeatureTests.swift b/apps/swift/Tests/PackRatMacUITests/MacHomeFeatureTests.swift new file mode 100644 index 0000000000..a0d7af9060 --- /dev/null +++ b/apps/swift/Tests/PackRatMacUITests/MacHomeFeatureTests.swift @@ -0,0 +1,56 @@ +import XCTest + +final class MacHomeFeatureTests: MacUITestCase { + func testPrimaryHomeTileOpensPacksOnMac() { + goToSidebar("Home", expected: "Home") + tapHomeTile("home_tile_my_packs") + XCTAssertTrue( + app.buttons["New Pack"].waitForExistence(timeout: 8) + || app.staticTexts["Packs"].waitForExistence(timeout: 2), + "Packs content must appear after selecting the home tile" + ) + } + + func testShoppingListTileSupportsAddToggleClearAndDone() { + goToSidebar("Home", expected: "Home") + tapHomeTile("home_tile_shopping_list") + + XCTAssertTrue( + app.staticTexts["Shopping List Empty"].waitForExistence(timeout: 5) + || app.buttons["shopping_add_item"].waitForExistence(timeout: 5), + "Shopping List sheet must appear" + ) + + waitFor(app.buttons["shopping_add_item"], timeout: 5).tap() + XCTAssertTrue(app.textFields["shopping_item_name"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["shopping_item_add"].isEnabled) + + let itemName = "Mac Stove \(Int(Date().timeIntervalSince1970))" + let nameField = waitFor(app.textFields["shopping_item_name"], timeout: 5) + nameField.tap() + nameField.typeText(itemName) + + waitFor(app.textFields["shopping_item_price"], timeout: 5).tap() + app.typeText("49.99") + + waitFor(app.buttons["shopping_item_add"], timeout: 5).tap() + XCTAssertTrue(app.staticTexts[itemName].waitForExistence(timeout: 5)) + + let toggle = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'shopping_toggle_'")).firstMatch + waitFor(toggle, timeout: 5).tap() + + app.buttons["shopping_done"].macTapIfExists() + } + + private func tapHomeTile(_ id: String) { + let tile = app.buttons[id] + if !tile.waitForExistence(timeout: 2) { + app.scrollViews.firstMatch.swipeUp() + } + if !tile.waitForExistence(timeout: 2) { + app.scrollViews.firstMatch.swipeUp() + } + waitFor(tile, timeout: 5, message: "\(id) must be visible on Home").tap() + } + +} diff --git a/apps/swift/Tests/PackRatMacUITests/MacNavigationTests.swift b/apps/swift/Tests/PackRatMacUITests/MacNavigationTests.swift new file mode 100644 index 0000000000..c8c47ca0ef --- /dev/null +++ b/apps/swift/Tests/PackRatMacUITests/MacNavigationTests.swift @@ -0,0 +1,24 @@ +import XCTest + +final class MacNavigationTests: MacUITestCase { + func testEverySidebarDestinationIsReachable() { + let destinations = [ + "Home", + "Packs", + "Trips", + "Weather", + "Assistant", + "Catalog", + "Templates", + "Trail Conditions", + "Feed", + "Guides", + "Gear Inventory", + "Wildlife" + ] + + for destination in destinations { + goToSidebar(destination) + } + } +} diff --git a/apps/swift/Tests/PackRatMacUITests/MacPackTripTests.swift b/apps/swift/Tests/PackRatMacUITests/MacPackTripTests.swift new file mode 100644 index 0000000000..c67737f1b7 --- /dev/null +++ b/apps/swift/Tests/PackRatMacUITests/MacPackTripTests.swift @@ -0,0 +1,77 @@ +import XCTest + +final class MacPackTripTests: MacUITestCase { + func testCreateOpenAndAddItemToPack() { + let packName = uniqueName("Mac E2E Pack") + let itemName = "Mac Tent \(Int(Date().timeIntervalSince1970))" + + createPack(named: packName) + waitFor(app.staticTexts[packName], timeout: 15).tap() + + let addItem = app.buttons["Add Item"].firstMatch + waitFor(addItem, timeout: 10).tap() + + let itemNameField = waitFor(textInput("Name", alternateLabels: ["item_name"]), timeout: 10) + itemNameField.tap() + itemNameField.typeText(itemName) + + let weightField = app.textFields["0"].exists ? app.textFields["0"] : app.textFields["item_weight"] + if weightField.waitForExistence(timeout: 3) { + weightField.tap() + weightField.typeText("500") + } + + app.buttons["Add"].tap() + XCTAssertTrue(app.staticTexts[itemName].waitForExistence(timeout: 15)) + } + + func testCreateOpenAndDeleteTrip() { + let tripName = uniqueName("Mac E2E Trip") + createTrip(named: tripName) + + waitFor(app.staticTexts[tripName], timeout: 15).tap() + XCTAssertTrue(app.staticTexts[tripName].waitForExistence(timeout: 10)) + + goToSidebar("Trips") + let cell = app.cells.containing(.staticText, identifier: tripName).firstMatch + if cell.waitForExistence(timeout: 5) { + cell.swipeLeft() + let deleteButton = app.buttons["Delete"] + if deleteButton.waitForExistence(timeout: 3) { + deleteButton.tap() + waitForAbsence(app.staticTexts[tripName], timeout: 10) + } + } + } + + private func createPack(named name: String) { + goToSidebar("Packs") + waitFor(app.buttons["new_pack_button"].firstMatch, timeout: 10).tap() + + let nameField = waitFor(textInput("Pack Name", alternateLabels: ["pack_name"]), timeout: 10) + nameField.tap() + nameField.typeText(name) + + let categoryButton = app.buttons.matching( + NSPredicate(format: "label CONTAINS 'Category' OR label == 'None'") + ).firstMatch + if categoryButton.waitForExistence(timeout: 3) { + categoryButton.tap() + let hiking = app.buttons["Hiking"].firstMatch + if hiking.waitForExistence(timeout: 3) { hiking.tap() } + } + + app.buttons["Create"].tap() + waitFor(app.staticTexts[name], timeout: 15) + } + + private func createTrip(named name: String) { + goToSidebar("Trips") + waitFor(app.buttons["plan_trip_button"].firstMatch, timeout: 10).tap() + let nameField = waitFor(textInput("Trip Name", alternateLabels: ["trip_name"]), timeout: 10) + nameField.tap() + nameField.typeText(name) + app.buttons["Create"].tap() + waitFor(app.staticTexts[name], timeout: 15) + } +} diff --git a/apps/swift/Tests/PackRatMacUITests/MacSecondaryFeatureTests.swift b/apps/swift/Tests/PackRatMacUITests/MacSecondaryFeatureTests.swift new file mode 100644 index 0000000000..b758faffc0 --- /dev/null +++ b/apps/swift/Tests/PackRatMacUITests/MacSecondaryFeatureTests.swift @@ -0,0 +1,116 @@ +import XCTest + +final class MacSecondaryFeatureTests: MacUITestCase { + func testAssistantInputSendAndClearControlsOnMac() { + goToSidebar("Assistant", expected: "AI Assistant") + + XCTAssertTrue(app.staticTexts["PackRat AI"].waitForExistence(timeout: 8)) + let input = waitFor(textInput( + "Ask about gear, trips, packing...", + alternateLabels: ["Ask about gear, trips, packing…", "chat_input"] + ), timeout: 5) + let send = waitFor(firstExisting([ + app.buttons["chat_send"], + app.buttons["Arrow Up Circle"], + app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'Arrow Up Circle'")).firstMatch + ], timeout: 3), timeout: 5) + XCTAssertFalse(send.isEnabled) + + input.tap() + input.typeText("Hi") + XCTAssertTrue(send.isEnabled) + send.tap() + XCTAssertTrue(app.staticTexts["Hi"].waitForExistence(timeout: 8)) + + let clear = app.buttons["Clear"].firstMatch + if clear.exists { + clear.tap() + } + } + + func testCatalogSearchAndClearControlsOnMac() { + goToSidebar("Catalog", expected: "Search the Gear Catalog") + + let searchField = textInput( + "Search tents, packs, sleeping bags…", + alternateLabels: ["Search tents, packs, sleeping bags...", "catalog_search"] + ) + waitFor(searchField, timeout: 8).tap() + searchField.typeText("tent") + + let loading = app.activityIndicators.firstMatch + _ = loading.waitForExistence(timeout: 2) + waitForSearchToSettle(loading) + + XCTAssertTrue( + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS[c] 'tent' OR label CONTAINS[c] 'oz' OR label CONTAINS[c] 'lb' OR label CONTAINS[c] 'no results'") + ).firstMatch.waitForExistence(timeout: 10) + || app.buttons["catalog_search_clear"].waitForExistence(timeout: 2), + "Catalog must show search results or a no-results state" + ) + + let clearButton = app.buttons.matching( + NSPredicate(format: "label CONTAINS[c] 'clear' OR label CONTAINS[c] 'xmark'") + ).firstMatch + if clearButton.waitForExistence(timeout: 3) { + clearButton.tap() + XCTAssertTrue(app.staticTexts["Search the Gear Catalog"].waitForExistence(timeout: 5)) + } + } + + func testTemplateFormControlsOnMac() { + goToSidebar("Templates", expected: "New Template") + waitFor(app.buttons["new_template_button"].firstMatch, timeout: 10).tap() + + XCTAssertTrue(textInput("Name", alternateLabels: ["template_name"]).waitForExistence(timeout: 5)) + XCTAssertTrue( + app.buttons.matching(NSPredicate(format: "label CONTAINS 'Category'")).firstMatch.waitForExistence(timeout: 5) + || app.staticTexts["Category"].waitForExistence(timeout: 2), + "Template form must expose category controls" + ) + app.buttons["Cancel"].macTapIfExists() + } + + func testFeedComposerControlsOnMac() { + goToSidebar("Feed", expected: "Community Feed") + waitFor(app.buttons["new_post_button"].firstMatch, timeout: 10).tap() + + let editor = waitFor(app.textViews["feed_compose_caption"], timeout: 8) + let post = waitFor(app.buttons["Post"], timeout: 5) + XCTAssertFalse(post.isEnabled) + XCTAssertTrue( + app.staticTexts["feed_compose_counter"].waitForExistence(timeout: 5) + || app.staticTexts.matching(NSPredicate(format: "label CONTAINS '/ 500'")).firstMatch.waitForExistence(timeout: 2), + "Feed composer must show the character counter" + ) + + editor.tap() + editor.typeText("Mac E2E composer check") + XCTAssertTrue(post.isEnabled) + app.buttons["Cancel"].macTapIfExists() + } + + func testTrailReportFormControlsOnMac() { + goToSidebar("Trail Conditions", expected: "Submit Report") + waitFor(app.buttons["trail_submit_report_toolbar"].firstMatch, timeout: 10).tap() + + XCTAssertTrue(textInput("Trail Name", alternateLabels: ["trail_name"]).waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["Submit"].isEnabled) + + for hazard in ["Downed trees", "Muddy sections", "Ice"] { + XCTAssertTrue( + toggleControl(hazard, alternateLabels: ["trail_hazard_\(hazard.replacingOccurrences(of: " ", with: "_"))"]) + .waitForExistence(timeout: 5) + ) + } + + app.buttons["Cancel"].macTapIfExists() + } + + private func waitForSearchToSettle(_ indicator: XCUIElement) { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: indicator) + _ = XCTWaiter.wait(for: [expectation], timeout: 15) + } +} diff --git a/apps/swift/Tests/PackRatMacUITests/MacSmokeTests.swift b/apps/swift/Tests/PackRatMacUITests/MacSmokeTests.swift new file mode 100644 index 0000000000..26d6b21db4 --- /dev/null +++ b/apps/swift/Tests/PackRatMacUITests/MacSmokeTests.swift @@ -0,0 +1,52 @@ +import XCTest + +final class MacSmokeTests: XCTestCase { + func testLoginScreenAppearsOnMac() { + let app = XCUIApplication() + app.launchArguments.append("--disable-animations") + app.launchArguments.append("--reset-auth") + app.launch() + defer { app.terminate() } + + XCTAssertTrue( + app.textFields["login_email"].waitForExistence(timeout: 10), + "macOS app should launch to the login screen when auth is reset" + ) + XCTAssertTrue(app.secureTextFields["login_password"].exists) + XCTAssertTrue(app.buttons["login_submit"].exists) + } + + func testSuccessfulLoginOnMacReachesPrimaryChrome() throws { + let app = XCUIApplication() + app.launchArguments.append("--disable-animations") + app.launchArguments.append("--reset-auth") + if let apiBaseURL = ProcessInfo.processInfo.environment["E2E_API_BASE_URL"], !apiBaseURL.isEmpty { + app.launchEnvironment["E2E_API_BASE_URL"] = apiBaseURL + } + app.launch() + defer { app.terminate() } + + let email = ProcessInfo.processInfo.environment["E2E_EMAIL"] ?? "" + let password = ProcessInfo.processInfo.environment["E2E_PASSWORD"] ?? "" + guard !email.isEmpty, !password.isEmpty else { + throw XCTSkip("E2E_EMAIL and E2E_PASSWORD are required for macOS UI smoke tests") + } + + let emailField = app.textFields["login_email"] + XCTAssertTrue(emailField.waitForExistence(timeout: 10)) + emailField.tap() + emailField.typeText(email) + + let passwordField = app.secureTextFields["login_password"] + passwordField.tap() + passwordField.typeText(password) + + app.buttons["login_submit"].tap() + + let homeTitle = app.staticTexts["Home"] + XCTAssertTrue( + homeTitle.waitForExistence(timeout: 20), + "macOS app should reach the authenticated primary interface after login" + ) + } +} diff --git a/apps/swift/Tests/PackRatMacUITests/MacUITestCase.swift b/apps/swift/Tests/PackRatMacUITests/MacUITestCase.swift new file mode 100644 index 0000000000..bb8b524496 --- /dev/null +++ b/apps/swift/Tests/PackRatMacUITests/MacUITestCase.swift @@ -0,0 +1,164 @@ +import XCTest + +class MacUITestCase: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments.append("--disable-animations") + app.launchArguments.append("--ui-testing") + app.launchArguments.append("--reset-auth") + if let apiBaseURL = ProcessInfo.processInfo.environment["E2E_API_BASE_URL"], !apiBaseURL.isEmpty { + app.launchEnvironment["E2E_API_BASE_URL"] = apiBaseURL + } + app.launch() + try loginIfNeeded() + } + + override func tearDownWithError() throws { + app.terminate() + try super.tearDownWithError() + } + + func loginIfNeeded() throws { + if app.staticTexts["Home"].waitForExistence(timeout: 2) { return } + + let email = ProcessInfo.processInfo.environment["E2E_EMAIL"] ?? "" + let password = ProcessInfo.processInfo.environment["E2E_PASSWORD"] ?? "" + guard !email.isEmpty, !password.isEmpty else { + throw XCTSkip("E2E_EMAIL and E2E_PASSWORD are required for macOS UI tests") + } + + let emailField = app.textFields["login_email"] + XCTAssertTrue(emailField.waitForExistence(timeout: 10), "Login screen must appear") + emailField.tap() + emailField.typeText(email) + + let passwordField = app.secureTextFields["login_password"] + passwordField.tap() + passwordField.typeText(password) + + app.buttons["login_submit"].tap() + XCTAssertTrue( + app.staticTexts["Home"].waitForExistence(timeout: 20), + "Home content must appear after login" + ) + } + + func goToSidebar(_ label: String, expected: String? = nil) { + let destinations = [ + "Home": "home", + "Packs": "packs", + "Trips": "trips", + "Weather": "weather", + "Assistant": "chat", + "Catalog": "catalog", + "Templates": "templates", + "Trail Conditions": "trailConditions", + "Feed": "feed", + "Guides": "guides", + "Gear Inventory": "gearInventory", + "Wildlife": "wildlife" + ] + guard let rawValue = destinations[label] else { + XCTFail("Unknown sidebar item '\(label)'") + return + } + + waitFor(app.buttons["sidebar_nav_\(rawValue)"], timeout: 5, message: "\(label) sidebar item must exist").tap() + waitFor(app.descendants(matching: .any)["screen_\(rawValue)"], timeout: 8, message: "\(label) screen must appear after sidebar selection") + + if let expectedLabel = expected { + XCTAssertTrue( + firstExisting([ + app.staticTexts[expectedLabel], + app.buttons[expectedLabel], + app.searchFields[expectedLabel], + app.textFields[expectedLabel] + ], timeout: 8).exists, + "\(expectedLabel) content must appear after selecting \(label)" + ) + } + } + + func firstExisting(_ elements: [XCUIElement], timeout: TimeInterval = 5) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if let element = elements.first(where: { $0.exists }) { + return element + } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } while Date() < deadline + + return elements.first ?? app.staticTexts.firstMatch + } + + func textInput(_ label: String, alternateLabels: [String] = []) -> XCUIElement { + let labels = [label] + alternateLabels + let candidates = labels.flatMap { candidate in + [ + app.textFields[candidate], + app.searchFields[candidate], + app.secureTextFields[candidate], + app.textViews[candidate], + app.descendants(matching: .any)[candidate] + ] + } + return firstExisting(candidates, timeout: 3) + } + + func toggleControl(_ label: String, alternateLabels: [String] = []) -> XCUIElement { + let labels = [label] + alternateLabels + let candidates = labels.flatMap { candidate in + [ + app.switches[candidate], + app.checkBoxes[candidate], + app.buttons[candidate], + app.descendants(matching: .any)[candidate] + ] + } + return firstExisting(candidates, timeout: 3) + } + + @discardableResult + func waitFor(_ element: XCUIElement, timeout: TimeInterval = 10, message: String? = nil) -> XCUIElement { + let msg = message ?? "\(element.description) did not appear within \(timeout)s" + XCTAssertTrue(element.waitForExistence(timeout: timeout), msg) + return element + } + + func waitForAbsence(_ element: XCUIElement, timeout: TimeInterval = 10) { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + let result = XCTWaiter.wait(for: [expectation], timeout: timeout) + XCTAssertEqual(result, .completed, "\(element.description) should have disappeared") + } + + func uniqueName(_ prefix: String) -> String { + "\(prefix) \(Int(Date().timeIntervalSince1970))" + } + + override func tearDown() { + if let testRun, testRun.totalFailureCount > 0 { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = "Failure-\(name)" + attachment.lifetime = .keepAlways + add(attachment) + + let dump = app?.debugDescription ?? "no app" + let textAttachment = XCTAttachment(string: dump) + textAttachment.name = "Hierarchy-\(name)" + textAttachment.lifetime = .keepAlways + add(textAttachment) + } + super.tearDown() + } +} + +extension XCUIElement { + func macTapIfExists() { + if exists { tap() } + } +} diff --git a/apps/swift/Tests/PackRatMacUITests/MacWeatherTests.swift b/apps/swift/Tests/PackRatMacUITests/MacWeatherTests.swift new file mode 100644 index 0000000000..7953e24606 --- /dev/null +++ b/apps/swift/Tests/PackRatMacUITests/MacWeatherTests.swift @@ -0,0 +1,49 @@ +import XCTest + +final class MacWeatherTests: MacUITestCase { + func testLocationSearchAndForecastLoadOnMac() { + goToSidebar("Weather") + + let searchField = textInput( + "Search locations...", + alternateLabels: ["Search locations…", "weather_location_search"] + ) + waitFor(searchField, timeout: 10).tap() + searchField.typeText("Denver") + + let result = app.buttons.matching(NSPredicate(format: "label CONTAINS 'Denver'")).firstMatch + waitFor(result, timeout: 10).tap() + + XCTAssertTrue( + app.staticTexts["10-Day Forecast"].waitForExistence(timeout: 20) + || app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Forecast'")).firstMatch.waitForExistence(timeout: 20), + "Forecast content must load after selecting a Denver location" + ) + } + + func testAlertPreferencesToolbarFlowOnMac() { + goToSidebar("Weather") + + let preferences = app.buttons["weather_alert_preferences_button"].firstMatch + if preferences.waitForExistence(timeout: 3) { + preferences.tap() + } else { + openOverflowMenu() + waitFor(app.buttons["Alert Preferences"], timeout: 8).tap() + } + + XCTAssertTrue(toggleControl("Weather Notifications", alternateLabels: ["weather_notifications_toggle"]).waitForExistence(timeout: 5)) + let highWinds = waitFor(toggleControl("High Winds", alternateLabels: ["high_winds_toggle"]), timeout: 5) + highWinds.tap() + highWinds.tap() + } + + private func openOverflowMenu() { + let overflow = app.buttons["OverflowBarButtonItem"] + if overflow.waitForExistence(timeout: 2) { + overflow.tap() + return + } + waitFor(app.buttons.matching(NSPredicate(format: "identifier == 'OverflowBarButtonItem'")).firstMatch).tap() + } +} diff --git a/apps/swift/Tests/PackRatUITests/AppUITestCase.swift b/apps/swift/Tests/PackRatUITests/AppUITestCase.swift index e756ee5218..626f17c718 100644 --- a/apps/swift/Tests/PackRatUITests/AppUITestCase.swift +++ b/apps/swift/Tests/PackRatUITests/AppUITestCase.swift @@ -37,6 +37,9 @@ class AppUITestCase: XCTestCase { // while still exercising authenticated API routes. app.launchArguments.append("--reset-auth") app.launchArguments.append(contentsOf: additionalLaunchArguments) + if let apiBaseURL = ProcessInfo.processInfo.environment["E2E_API_BASE_URL"], !apiBaseURL.isEmpty { + app.launchEnvironment["E2E_API_BASE_URL"] = apiBaseURL + } let bundle = Bundle(for: AppUITestCase.self) let seededAuthToken = (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_SESSION_TOKEN") as? String) diff --git a/apps/swift/Tests/PackRatUITests/AuthTests.swift b/apps/swift/Tests/PackRatUITests/AuthTests.swift index 290caf03d5..2fe69979dc 100644 --- a/apps/swift/Tests/PackRatUITests/AuthTests.swift +++ b/apps/swift/Tests/PackRatUITests/AuthTests.swift @@ -9,6 +9,9 @@ final class AuthTests: AppUITestCase { // Force logged-out state so the login screen is reachable. app.launchArguments.append("--reset-auth") app.launchArguments.append("--allow-e2e-login-seed") + if let apiBaseURL = ProcessInfo.processInfo.environment["E2E_API_BASE_URL"], !apiBaseURL.isEmpty { + app.launchEnvironment["E2E_API_BASE_URL"] = apiBaseURL + } injectE2EAuthEnvironment() app.launch() } diff --git a/apps/swift/Tests/PackRatUITests/HomeTileTests.swift b/apps/swift/Tests/PackRatUITests/HomeTileTests.swift new file mode 100644 index 0000000000..abeddb2662 --- /dev/null +++ b/apps/swift/Tests/PackRatUITests/HomeTileTests.swift @@ -0,0 +1,121 @@ +import XCTest + +final class HomeTileTests: AppUITestCase { + private struct Tile { + let id: String + let destinationTitle: String? + } + + private let navigationTiles: [Tile] = [ + Tile(id: "home_tile_my_packs", destinationTitle: "Packs"), + Tile(id: "home_tile_trips", destinationTitle: "Trips"), + Tile(id: "home_tile_weather", destinationTitle: "Weather"), + Tile(id: "home_tile_ai_assistant", destinationTitle: "AI Assistant"), + Tile(id: "home_tile_gear_inventory", destinationTitle: "Gear Inventory"), + Tile(id: "home_tile_pack_templates", destinationTitle: "Pack Templates"), + Tile(id: "home_tile_guides", destinationTitle: "Guides"), + Tile(id: "home_tile_catalog", destinationTitle: "Gear Catalog"), + Tile(id: "home_tile_community_feed", destinationTitle: "Community Feed"), + Tile(id: "home_tile_trail_conditions", destinationTitle: "Trail Conditions"), + Tile(id: "home_tile_wildlife_id", destinationTitle: "Wildlife ID") + ] + + func testEveryHomeNavigationTileOpensDestination() { + for tile in navigationTiles { + goToTab("Home") + tapHomeTile(tile.id) + + guard let destinationTitle = tile.destinationTitle else { continue } + XCTAssertTrue( + app.navigationBars[destinationTitle].waitForExistence(timeout: 8), + "\(tile.id) must open \(destinationTitle)" + ) + } + } + + func testSeasonSuggestionsTileOpensAndDismissesSheet() { + goToTab("Home") + tapHomeTile("home_tile_season_suggestions") + + XCTAssertTrue( + app.staticTexts["AI-Powered Packing Tips"].waitForExistence(timeout: 5) + || app.staticTexts["Season Suggestions"].waitForExistence(timeout: 5), + "Season Suggestions sheet must appear" + ) + app.buttons["Done"].tapIfExists() + } + + func testShoppingListTileSupportsAddToggleClearAndDone() { + goToTab("Home") + tapHomeTile("home_tile_shopping_list") + + XCTAssertTrue( + app.navigationBars.matching(NSPredicate(format: "identifier BEGINSWITH 'Shopping List'")).firstMatch + .waitForExistence(timeout: 5) + || app.staticTexts["Shopping List Empty"].waitForExistence(timeout: 5), + "Shopping List sheet must appear" + ) + + waitFor(app.buttons["shopping_add_item"], timeout: 5).tap() + XCTAssertTrue(app.navigationBars["Add Item"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["shopping_item_add"].isEnabled) + + let itemName = "E2E Stove \(Int(Date().timeIntervalSince1970))" + let nameField = waitFor(app.textFields["shopping_item_name"], timeout: 5) + nameField.tap() + nameField.typeText(itemName) + + waitFor(app.textFields["shopping_item_price"], timeout: 5).tap() + app.typeText("49.99") + + waitFor(app.buttons["shopping_item_add"], timeout: 5).tap() + XCTAssertTrue(app.staticTexts[itemName].waitForExistence(timeout: 5)) + + let toggle = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'shopping_toggle_'")).firstMatch + waitFor(toggle, timeout: 5).tap() + + openOverflowMenu() + waitFor(menuButton(id: "shopping_toggle_purchased_visibility", label: "Show Purchased"), timeout: 5).tap() + XCTAssertTrue(app.staticTexts[itemName].waitForExistence(timeout: 5)) + + openOverflowMenu() + waitFor(menuButton(id: "shopping_clear_purchased", label: "Clear Purchased"), timeout: 5).tap() + XCTAssertFalse( + app.staticTexts[itemName].waitForExistence(timeout: 3), + "Clear Purchased must remove purchased items" + ) + + waitFor(app.buttons["shopping_done"], timeout: 5).tap() + XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 5)) + } + + private func tapHomeTile(_ id: String) { + let tile = app.buttons[id] + if !tile.waitForExistence(timeout: 3) { + app.swipeUp() + } + if !tile.waitForExistence(timeout: 3) { + app.swipeUp() + } + waitFor(tile, timeout: 5, message: "\(id) must be visible on Home").tap() + } + + private func openOverflowMenu() { + let overflow = app.buttons["OverflowBarButtonItem"] + if overflow.waitForExistence(timeout: 2) { + overflow.tap() + return + } + waitFor( + app.buttons.matching(NSPredicate(format: "identifier == 'OverflowBarButtonItem'")).firstMatch, + timeout: 5, + message: "Overflow menu must be available" + ).tap() + } + + private func menuButton(id: String, label: String) -> XCUIElement { + let identified = app.buttons[id] + if identified.exists { return identified } + return app.buttons[label] + } +} diff --git a/apps/swift/Tests/PackRatUITests/PackSubFlowTests.swift b/apps/swift/Tests/PackRatUITests/PackSubFlowTests.swift index 25f8e8bbe3..c96fc0f16e 100644 --- a/apps/swift/Tests/PackRatUITests/PackSubFlowTests.swift +++ b/apps/swift/Tests/PackRatUITests/PackSubFlowTests.swift @@ -129,6 +129,16 @@ final class PackSubFlowTests: AppUITestCase { waitFor(nameField) nameField.tap() nameField.typeText(name) + + let categoryButton = app.buttons.matching( + NSPredicate(format: "label CONTAINS 'Category' OR label == 'None'") + ).firstMatch + if categoryButton.waitForExistence(timeout: 3) { + categoryButton.tap() + let hiking = app.buttons["Hiking"].firstMatch + if hiking.waitForExistence(timeout: 3) { hiking.tap() } + } + app.buttons["Create"].tap() waitFor(app.staticTexts[name], timeout: 15) } diff --git a/apps/swift/Tests/PackRatUITests/ScreenshotSmokeTests.swift b/apps/swift/Tests/PackRatUITests/ScreenshotSmokeTests.swift new file mode 100644 index 0000000000..f199282f31 --- /dev/null +++ b/apps/swift/Tests/PackRatUITests/ScreenshotSmokeTests.swift @@ -0,0 +1,43 @@ +import XCTest + +/// Focused visual smoke pass for producing reviewable simulator screenshots. +/// +/// Run with `-only-testing:PackRatUITests/ScreenshotSmokeTests`. Screenshots +/// are attached to the `.xcresult`; host-side PNG capture can be done with +/// `xcrun simctl io screenshot`. +final class ScreenshotSmokeTests: AppUITestCase { + func testCaptureCoreScreens() throws { + capture("02-home") + + goToTab("Packs") + XCTAssertTrue(app.navigationBars["Packs"].waitForExistence(timeout: 8)) + capture("03-packs") + + goToTab("Weather") + XCTAssertTrue(app.navigationBars["Weather"].waitForExistence(timeout: 8)) + let searchField = app.textFields["Search locations..."].exists + ? app.textFields["Search locations..."] + : app.textFields["Search locations…"] + XCTAssertTrue(searchField.waitForExistence(timeout: 10)) + searchField.tap() + searchField.typeText("Denver") + let firstResult = app.buttons.matching( + NSPredicate(format: "label CONTAINS 'Denver' AND label CONTAINS ','") + ).firstMatch + XCTAssertTrue(firstResult.waitForExistence(timeout: 10)) + firstResult.tap() + XCTAssertTrue(app.staticTexts["10-Day Forecast"].waitForExistence(timeout: 20)) + if app.keyboards.firstMatch.exists { + app.keyboards.buttons["Return"].tapIfExists() + } + waitForAbsence(app.keyboards.firstMatch, timeout: 3) + capture("04-weather") + } + + private func capture(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/docs/ci/swift-e2e-runner.md b/docs/ci/swift-e2e-runner.md new file mode 100644 index 0000000000..5913b8e8d2 --- /dev/null +++ b/docs/ci/swift-e2e-runner.md @@ -0,0 +1,106 @@ +# Swift E2E Runner + +This repo has two iOS-era app stacks for now: + +- Expo app: production iOS app, covered by Maestro in `.github/workflows/e2e-tests.yml`. +- Swift app: native macOS app first, with exploratory Swift iOS coverage, covered by XCTest/XCUITest in `.github/workflows/swift-e2e.yml`. + +Do not move the Swift app to Maestro. XCUITest is the native Apple UI automation layer and gives better result bundles, accessibility hierarchy data, simulator integration, and macOS app coverage. + +## GitHub Runner + +The full macOS Swift UI suite should run on a persistent self-hosted Mac runner. GitHub-hosted macOS runners are fine for iOS simulator work, but desktop macOS app automation depends on machine-level state. + +Required labels: + +```text +self-hosted +macOS +packrat-e2e +``` + +The label `packrat-e2e` is registered in `.github/actionlint.yaml` so workflow linting accepts the custom runner. + +## Machine Setup + +Install and select the expected Xcode version: + +```sh +sudo xcode-select -s /Applications/Xcode-26.2.0.app/Contents/Developer +xcodebuild -version +``` + +Enable Automation Mode without per-run prompts: + +```sh +sudo automationmodetool enable-automationmode-without-authentication +automationmodetool status +``` + +The status may still show Automation Mode disabled until a process enables it, but it must say the device does not require user authentication to enable Automation Mode. + +Keep the runner attached to a logged-in GUI session. macOS UI tests are less reliable from a headless or locked desktop session because Accessibility and event synthesis depend on the user session. + +Run macOS UI tests under `caffeinate` so long cold builds do not let the display or user session idle before XCTest activates the app: + +```sh +caffeinate -dimsu bun run e2e:swift:mac-smoke +caffeinate -dimsu bun run e2e:swift:mac-ui +``` + +## Required Secrets + +Set these GitHub repository secrets: + +```text +E2E_TEST_EMAIL +E2E_TEST_PASSWORD +SWIFT_E2E_API_BASE_URL +NEON_DEV_DATABASE_URL +PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN +``` + +`SWIFT_E2E_API_BASE_URL` should point at a stable dev or staging API. Localhost is fine for manual local runs, but CI should prefer a deployed test API unless the runner also starts and owns a local API process. + +## Workflow Behavior + +Pull requests: + +- Run `bun run e2e:swift:mac-smoke` on the self-hosted Mac runner. +- Upload `.xcresult`, screenshots, and failure triage artifacts. + +Pushes to `main` or `development`, scheduled runs, and manual macOS runs: + +- Run `bun run e2e:swift:mac-ui`. +- Treat this as the primary Swift app confidence signal. + +Scheduled runs and manual iOS runs: + +- Run `bun run e2e:swift:ios`. +- Keep this separate from Expo/Maestro because the Swift iOS app is exploratory while the Expo iOS app remains active. + +## Local Commands + +```sh +bun swift +bun run test:swift:runner +bun run test:swift:unit +E2E_API_BASE_URL=http://localhost:8788 bun run e2e:swift:mac-smoke +E2E_API_BASE_URL=http://localhost:8788 bun run e2e:swift:mac-ui +E2E_API_BASE_URL=http://localhost:8788 bun run e2e:swift:ios-smoke +E2E_API_BASE_URL=http://localhost:8788 bun run e2e:swift:ios +``` + +The runner injects `E2E_EMAIL`, `E2E_PASSWORD`, `E2E_API_BASE_URL`, and `E2E_SCREENSHOT_DIR` into the generated Xcode schemes at runtime. It redacts credential-like values from `xcodebuild` output. + +## Data Hygiene + +Current Swift UI tests isolate data by creating records with unique names and IDs. This makes repeated runs safe, but it can leave historical test data in the shared E2E account. + +If the E2E account becomes noisy, add one of these: + +- API cleanup helpers that delete records created with the current run prefix. +- A test-only reset endpoint available only in development/staging. +- Dedicated test tenancy per run if backend isolation becomes critical. + +Until then, avoid assertions that depend on an empty account. diff --git a/docs/plans/2026-05-05-001-feat-swift-e2e-coverage-plan.md b/docs/plans/2026-05-05-001-feat-swift-e2e-coverage-plan.md new file mode 100644 index 0000000000..701fc55b57 --- /dev/null +++ b/docs/plans/2026-05-05-001-feat-swift-e2e-coverage-plan.md @@ -0,0 +1,430 @@ +--- +title: "feat: Harden Swift end-to-end coverage" +type: feat +status: active +date: 2026-05-05 +--- + +# feat: Harden Swift end-to-end coverage + +## Overview + +Build on `claude/swift-mac-app-effort-tTGd7` by creating an isolated worktree and turning the current Swift XCTest/XCUITest surface into reliable iOS and macOS end-to-end coverage. The goal is confidence that the new native Swift app can build, launch, authenticate, navigate core flows, and exercise user-visible behavior without depending on fragile shared state or stale branch assumptions. + +## Problem Frame + +The Swift app branch is active and other agents are pushing to it. It already adds a large `apps/swift/` tree, XcodeGen project configuration, iOS unit tests, and iOS UI tests. The current coverage is a strong start, but it does not yet prove the native app is working end to end across both promised platforms. It also has concurrency risks: agents can unknowingly base work on stale remote commits, modify the same test files, or leave test data in a shared E2E account that changes later test outcomes. + +## Requirements Trace + +- R1. Create and work from a fresh worktree based on the latest `origin/claude/swift-mac-app-effort-tTGd7`, not the dirty `development` checkout. +- R2. Verify the Swift project can regenerate, build, and run unit tests for the current branch state. +- R3. Expand iOS XCUITest coverage to cover authentication, navigation, packs, trips, templates, weather, catalog, chat, feed, trail conditions, and the newer Expo-parity surfaces. +- R4. Add macOS build and E2E coverage so the native Mac app is tested as a first-class target, not inferred from iOS success. +- R5. Make E2E tests isolated and repeatable when multiple agents are running against the same branch and shared backend. +- R6. Provide a single repeatable test runner path for local agents and CI, with clear output and failure artifacts. +- R7. Keep generated Xcode artifacts out of git and preserve XcodeGen as the source of truth. + +## Scope Boundaries + +- This plan does not implement new product features unless a test exposes a missing accessibility hook, test-only launch flag, or deterministic fixture path required for coverage. +- This plan does not replace the existing Expo Maestro suite. It covers the native Swift app under `apps/swift/`. +- This plan does not solve backend E2E data isolation globally. It defines the Swift client-side contract and minimal API/data assumptions needed for stable tests. +- This plan does not require pixel-perfect parity between Expo and Swift UI. + +### Deferred to Separate Tasks + +- Server-side dedicated E2E tenancy or reset APIs: separate backend plan if current API cannot support deterministic cleanup. +- Full App Store signing/archive validation: separate release-readiness plan after functional E2E passes. + +## Context & Research + +### Relevant Code and Patterns + +- `apps/swift/project.yml` defines `PackRat-iOS`, `PackRat-macOS`, `PackRatTests`, and `PackRatUITests`. Tests currently target iOS only. +- `apps/swift/scripts/run-e2e.ts` injects `E2E_EMAIL` and `E2E_PASSWORD` into the generated `PackRat-iOS.xcscheme`, then runs `xcodebuild test -only-testing:PackRatUITests`. +- `apps/swift/Tests/PackRatUITests/AppUITestCase.swift` logs in through the UI and launches with `--disable-animations`. +- `apps/swift/Tests/PackRatUITests/AuthTests.swift` has its own login-state handling and recently added reset-auth behavior. +- Existing UI suites already cover Auth, Navigation, Packs, Pack subflows, Trips, Weather, Catalog, Chat, Feed, Templates, Season Suggestions, Trail Conditions, and More tabs. +- Existing unit suites cover model formatting/decoding, endpoint construction, request encoding, service payloads, and view-model filtering/state. +- `docs/plans/2026-05-02-001-refactor-swift-xcodegen-multiplatform-plan.md` establishes XcodeGen, shared SwiftUI source, bundle IDs, and generated project expectations. +- `docs/plans/2026-05-02-002-feat-swift-expo-parity-plan.md` establishes the Swift feature-parity roadmap and identifies the user-facing surfaces that need coverage. +- `CLAUDE.md` notes that SourceKit can report false positives and that build success is the real signal. + +### Institutional Learnings + +- No directly matching Swift/Xcode E2E solution exists under `docs/solutions/`. +- Existing PackRat E2E convention uses durable scripts and named flows rather than one-off manual commands. +- The current repo often has dirty unrelated files; implementation must avoid reverting unrelated changes. + +### External References + +- External research intentionally skipped. The repo already contains the relevant XcodeGen/XCTest structure and the next decisions are local: target definitions, runner shape, data isolation, and coverage gaps. + +## Key Technical Decisions + +- Use a new worktree from the moving Swift branch: Execution should begin by fetching `origin/claude/swift-mac-app-effort-tTGd7` and creating a new worktree branch from that remote ref. Re-fetch before editing and before final verification because other agents are pushing. +- Treat `project.yml` as authoritative: Add or change test targets and schemes in `apps/swift/project.yml`, then regenerate with the existing `bun swift` flow. Do not hand-edit generated `.xcodeproj` files except through the existing ephemeral scheme credential injection in the runner. +- Split test concerns by layer: Unit tests cover pure models, request encoding, view-model state, and test doubles. UI tests cover app launch, auth, navigation, form validation, CRUD flows, and cross-screen behavior. Runner tests cover environment validation and command construction. +- Add macOS coverage explicitly: Create macOS unit/UI test targets or schemes rather than assuming the iOS test bundle validates macOS behavior. At minimum, macOS E2E must launch, authenticate, exercise sidebar navigation, open settings, and verify multi-window commands for packs/trips where test data exists. +- Make E2E data unique by default: Test-created records should include a run-scoped identifier and clean up through UI or API-backed helper paths where possible. Tests must not depend on a blank account. +- Prefer accessibility identifiers over brittle text queries: Add identifiers only where tests need stable hooks. Keep them semantic and user-flow oriented, such as `pack_form_name`, `pack_item_add`, or `weather_search_field`. +- Preserve live-backend confidence while enabling deterministic smoke mode: Full E2E should run against the configured backend with real credentials, but test-only launch arguments may seed local state, clear auth, disable animations, and point at a local/staging API when available. +- Keep credentials out of logs and durable artifacts: The runner may validate that credentials are present and inject them into ephemeral generated scheme state, but it must never print credential values or include them in committed files, result bundle notes, screenshots, or CI logs. + +## Open Questions + +### Resolved During Planning + +- Should the initial execution use a worktree? Yes. The main checkout is dirty and the Swift branch is moving. +- Is the current Swift test surface empty? No. It already has meaningful unit and iOS UI suites under `apps/swift/Tests`. +- Is macOS currently covered by tests? Not directly. `PackRat-macOS` builds as an app target, but test targets in `project.yml` are iOS-only. +- Should this replace Maestro? No. Maestro remains the Expo E2E suite; Swift should use XCTest/XCUITest for native app coverage. + +### Deferred to Implementation + +- Exact simulator names and installed runtimes: discover at execution time from local Xcode. +- Whether the current backend supports safe cleanup for every entity: verify while hardening tests; if not, isolate through unique names and document residual test data. +- Whether macOS UI tests need a separate app wrapper target or can share most helpers with conditional compilation: decide after the first generated project test pass. +- Whether local macOS UI automation needs additional Accessibility/TCC permissions on the developer machine or CI runner: discover during the first macOS smoke execution and document the setup if required. + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TD + A[Fetch moving Swift branch] --> B[Create isolated worktree] + B --> C[Regenerate Xcode project from project.yml] + C --> D[Build iOS and macOS schemes] + D --> E[Run unit tests] + E --> F[Run iOS UI tests] + E --> G[Run macOS UI smoke tests] + F --> H[Collect result bundles and screenshots] + G --> H + H --> I[Fix app/test gaps] + I --> C +``` + +## Implementation Units + +- [ ] **Unit 1: Worktree and moving-branch execution guardrails** + +**Goal:** Establish an isolated implementation branch/worktree and a repeatable preflight that keeps agents synchronized with the latest Swift branch. + +**Requirements:** R1, R5 + +**Dependencies:** None + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `apps/swift/scripts/run-e2e.ts` +- Create: `apps/swift/scripts/run-e2e.test.ts` + +**Approach:** +- Create the implementation worktree from the latest `origin/claude/swift-mac-app-effort-tTGd7` using the repo worktree manager, with a branch name dedicated to Swift E2E hardening. +- Add runner preflight messaging that prints the current git branch, HEAD SHA, and upstream SHA before running E2E. +- Add a stale-branch warning when the local branch is behind its upstream, without blocking local smoke runs. +- Document the concurrency rule: re-fetch before touching `apps/swift/Tests/**`, `apps/swift/project.yml`, or shared runner scripts. + +**Execution note:** Start by characterizing the current runner behavior before changing it, because other agents may have already modified the same branch. + +**Patterns to follow:** +- `apps/swift/scripts/run-e2e.ts` for existing environment loading and scheme mutation. +- `CLAUDE.md` for repo-local workflow notes. + +**Test scenarios:** +- Happy path: runner prints current branch and HEAD before launching tests. +- Edge case: no upstream configured -> runner prints a non-fatal warning and continues. +- Edge case: upstream is ahead -> runner prints a clear stale-branch warning. +- Error path: runner output redacts environment values and never prints `E2E_PASSWORD`. +- Integration: worktree branch can regenerate the Xcode project without adding generated `.xcodeproj` files to git. + +**Verification:** +- A fresh worktree is available and `git status` there contains only intentional changes. +- Runner preflight output makes stale branch state visible before expensive test work starts. + +- [ ] **Unit 2: Baseline build and unit-test hardening** + +**Goal:** Make the current Swift unit-test suite a reliable first gate before UI tests run. + +**Requirements:** R2, R6, R7 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `apps/swift/project.yml` +- Modify: `apps/swift/Tests/PackRatTests/ModelTests.swift` +- Modify: `apps/swift/Tests/PackRatTests/NetworkTests.swift` +- Modify: `apps/swift/Tests/PackRatTests/ServiceTests.swift` +- Modify: `apps/swift/Tests/PackRatTests/ViewModelTests.swift` +- Modify: `apps/swift/scripts/run-e2e.ts` + +**Approach:** +- Verify `PackRatTests` compiles under the generated project and add missing unit coverage for code paths that UI tests depend on: auth reset flags, endpoint base URL selection, request encoding, and view-model empty/error states. +- Decide whether `PackRatTests` should run for both iOS and macOS schemes or whether a separate macOS unit-test target is required. The plan should not rely on iOS-only unit compilation to prove shared Swift source is valid on macOS. +- Add a runner mode that can run unit tests only, UI tests only, or all Swift tests. +- Keep unit tests independent of network and keychain residue. Tests that touch keychain state must use a test namespace or clear state in setup/teardown. + +**Patterns to follow:** +- Existing `Testing` framework suites in `ModelTests.swift` and `ViewModelTests.swift`. +- Existing request encoding tests in `ServiceTests.swift`. + +**Test scenarios:** +- Happy path: model decoding handles API payloads with nested packs, trips, weather, catalog, and generated types used by UI flows. +- Happy path: endpoint builder produces expected method, path, query, body, and auth flags. +- Edge case: unknown enum values decode into safe fallbacks where the app expects resilience. +- Edge case: keychain tests do not leak tokens between test cases. +- Error path: malformed server payloads used in view models surface a user-safe error state, not a crash. +- Integration: shared source unit tests compile under every platform scheme that claims to ship the shared Swift app. + +**Verification:** +- Unit tests pass from the generated Xcode project. +- Unit-test mode fails fast before any simulator UI work when pure Swift behavior is broken. + +- [ ] **Unit 3: iOS E2E isolation and shared helpers** + +**Goal:** Make existing iOS UI tests deterministic, isolated, and easier to extend. + +**Requirements:** R3, R5, R6 + +**Dependencies:** Unit 2 + +**Files:** +- Modify: `apps/swift/Tests/PackRatUITests/AppUITestCase.swift` +- Modify: `apps/swift/Tests/PackRatUITests/AuthTests.swift` +- Modify: `apps/swift/Sources/PackRat/Network/AuthManager.swift` +- Modify: `apps/swift/Sources/PackRat/PackRatApp.swift` +- Modify: `apps/swift/scripts/run-e2e.ts` + +**Approach:** +- Normalize launch arguments for UI tests: disable animations, optionally reset auth, and expose a run identifier to the app. +- Centralize login helpers so `AuthTests` can force logged-out state while all other suites can login once safely. +- Add reusable helpers for tab navigation, modal dismissal, unique names, eventual assertions, and cleanup. +- Add missing accessibility identifiers for controls that tests currently find through fragile labels or positions. +- Add lightweight accessibility checks for core controls where XCUITest can assert labels, hittability, and keyboard focus without turning the suite into a full accessibility audit. +- Ensure tests can run independently with `-only-testing` and in aggregate without hidden ordering dependencies. + +**Patterns to follow:** +- `AppUITestCase.swift` helper style. +- Existing accessibility identifiers in login fields and submit buttons. + +**Test scenarios:** +- Happy path: a non-auth test logs in when needed and reaches the tab bar. +- Happy path: an auth test starts logged out even if a previous suite saved tokens. +- Happy path: primary buttons and fields used by tests expose stable accessibility labels/identifiers. +- Edge case: running a single test method from a clean simulator works. +- Edge case: running the full UI suite after a failed previous run does not inherit broken modal/auth state. +- Error path: missing `E2E_EMAIL` or `E2E_PASSWORD` skips or fails with an actionable message before UI assertions cascade. + +**Verification:** +- `PackRatUITests` can run as a suite and individual tests can run by name. +- Failures point at the failed screen/control rather than timing out on unrelated prior state. + +- [ ] **Unit 4: Complete iOS feature-flow E2E coverage** + +**Goal:** Expand iOS XCUITests from reachability checks into end-to-end user flows for all major Swift surfaces. + +**Requirements:** R3, R5 + +**Dependencies:** Unit 3 + +**Files:** +- Modify: `apps/swift/Tests/PackRatUITests/NavigationTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/PackTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/PackSubFlowTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/TripTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/PackTemplateTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/WeatherTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/WeatherSubFlowTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/CatalogTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/ChatTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/FeedTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/TrailConditionTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/MoreTabsTests.swift` +- Modify: `apps/swift/Tests/PackRatUITests/SeasonSuggestionsTests.swift` +- Modify: Swift views under `apps/swift/Sources/PackRat/Features/**` only when stable identifiers or test-only launch handling are needed. + +**Approach:** +- For each feature, keep one fast reachability smoke test and add at least one durable create/read/update/delete or submit/validate flow where the UI supports it. +- Use run-scoped names for packs, trips, templates, posts, and trail reports. +- Prefer cleanup through UI after creating entities. If deletion is not available, use unique names and document leftover data. +- Cover negative paths that users can hit: empty submit buttons, invalid credentials, missing required fields, empty search, API unavailable messages, and form cancellation. +- Add coverage for newer parity surfaces from the Swift parity plan: Home, Gear Inventory, Guides, Wildlife, Shopping List, Weather Alerts, Season Suggestions, and Pack Template editing where implemented. + +**Patterns to follow:** +- Existing `PackTests.swift` create/open/delete flows. +- Existing `WeatherTests.swift` search/select forecast flow. +- Existing `FeedTests.swift` composer validation pattern. + +**Test scenarios:** +- Happy path: create a pack with a unique name, add an item, open item detail, edit pack name, then delete the pack. +- Happy path: create a trip with location/date data, open detail, verify displayed data, then delete it. +- Happy path: create a pack template with an item, open detail, edit metadata, then remove or archive it where supported. +- Happy path: search catalog for a known term and open a result detail. +- Happy path: search weather location, select it, verify forecast rows and alert controls. +- Happy path: submit a trail condition report with hazard toggles and verify it appears or returns a success state. +- Happy path: chat accepts a prompt, streams a response, and renders any known tool-result card without crashing. +- Edge case: empty required fields keep submit disabled across pack, trip, template, feed, and trail forms. +- Edge case: no results or empty state screens show actionable text and no crash. +- Error path: invalid login shows an error and does not reach the app shell. +- Integration: navigating across every primary and overflow tab preserves app state and does not force unexpected logout. + +**Verification:** +- The iOS UI suite covers every primary tab and major modal/sheet flow. +- New tests remain stable when run repeatedly against the same E2E account. + +- [ ] **Unit 5: macOS build and E2E coverage** + +**Goal:** Add direct macOS test coverage for the native Mac target. + +**Requirements:** R4, R6, R7 + +**Dependencies:** Unit 3 + +**Files:** +- Modify: `apps/swift/project.yml` +- Create: `apps/swift/Tests/PackRatMacUITests/AppMacUITestCase.swift` +- Create: `apps/swift/Tests/PackRatMacUITests/MacLaunchTests.swift` +- Create: `apps/swift/Tests/PackRatMacUITests/MacNavigationTests.swift` +- Create: `apps/swift/Tests/PackRatMacUITests/MacWindowTests.swift` +- Modify: `apps/swift/scripts/run-e2e.ts` +- Modify: `apps/swift/Sources/PackRat/Navigation/PackRatCommands.swift` +- Modify: `apps/swift/Sources/PackRat/Features/Preferences/PreferencesView.swift` + +**Approach:** +- Add a macOS UI test target and include it in a macOS test scheme. +- Share helper concepts with iOS tests but keep a separate base class because macOS uses windows, menus, and sidebar navigation rather than an iOS tab bar. +- Cover launch, login, sidebar navigation, settings/preferences, and opening pack/trip windows from the macOS-specific commands or buttons. +- Add stable accessibility identifiers for sidebar rows, settings controls, and open-window buttons where needed. +- Detect and document local prerequisites for macOS automation: UI testing permissions, simulator/device destination choice, signing requirements, and any CI runner limitation. + +**Patterns to follow:** +- `apps/swift/Sources/PackRat/PackRatApp.swift` macOS scenes. +- `apps/swift/Sources/PackRat/Shared/OpenWindowButton.swift`. +- `apps/swift/Tests/PackRatUITests/AppUITestCase.swift` for credential handling. + +**Test scenarios:** +- Happy path: macOS app launches to login or authenticated shell without crash. +- Happy path: login reaches the sidebar-based main window. +- Happy path: sidebar navigation reaches Packs, Trips, Weather, Catalog, Chat, Feed, Templates, Trail Conditions, and Profile/More surfaces. +- Happy path: Preferences opens and toggles a non-destructive setting. +- Happy path: opening a pack or trip in a separate window creates a new window with expected title/content when test data exists. +- Happy path: keyboard navigation can focus the sidebar and activate a destination. +- Edge case: closing a secondary window leaves the main window usable. +- Error path: invalid credentials keep the app on login with a visible error. +- Error path: missing macOS automation permissions fail with a clear setup message rather than a generic timeout. + +**Verification:** +- `PackRat-macOS` builds and has at least a smoke E2E suite that can run locally. +- macOS failures produce result bundles or screenshots comparable to iOS failures. + +- [ ] **Unit 6: Unified Swift E2E runner and artifacts** + +**Goal:** Make local and CI execution consistent, selective, and inspectable. + +**Requirements:** R2, R3, R4, R6 + +**Dependencies:** Units 2, 3, 5 + +**Files:** +- Modify: `apps/swift/scripts/run-e2e.ts` +- Create: `apps/swift/scripts/run-e2e.test.ts` +- Modify: `package.json` +- Modify: `.github/workflows/unit-tests.yml` +- Modify: `.github/workflows/e2e-tests.yml` +- Create: `apps/swift/README.md` + +**Approach:** +- Extend the runner to support clear modes such as all, unit, ios-ui, mac-ui, and focused `-only-testing` passthrough. +- Write result bundles into a predictable ignored directory under `apps/swift/TestResults/`. +- Print the selected scheme, destination, credentials status, API environment, git HEAD, and result bundle location. +- Redact all secret values from runner output and generated diagnostic summaries; print only presence/absence for credentials. +- Add package scripts for Swift unit and Swift E2E runs without disturbing existing Expo Maestro scripts. +- Update CI only after local behavior is stable, and keep macOS CI optional if runner availability or signing makes it impractical initially. + +**Patterns to follow:** +- Existing `bun e2e:swift` script. +- Existing GitHub workflow separation between unit and E2E tests. + +**Test scenarios:** +- Happy path: `unit` mode runs only `PackRatTests`. +- Happy path: `ios-ui` mode runs only `PackRatUITests`. +- Happy path: `mac-ui` mode runs only macOS UI tests. +- Happy path: focused arguments pass through to `xcodebuild` unchanged. +- Edge case: missing generated project gives an actionable instruction to regenerate. +- Edge case: missing credentials report only missing variable names, never partial values. +- Error path: failed `xcodebuild` exits non-zero and leaves a result bundle path in logs. +- Integration: CI can upload result bundles/screenshots when a Swift test job fails. + +**Verification:** +- One runner path can run every Swift test layer. +- A failed local test leaves enough artifacts to debug without rerunning immediately. + +- [ ] **Unit 7: Coverage review and gap closure** + +**Goal:** Audit the final Swift coverage against the app’s feature inventory and close or document remaining gaps. + +**Requirements:** R3, R4, R5, R6 + +**Dependencies:** Units 4, 5, 6 + +**Files:** +- Modify: `apps/swift/README.md` +- Modify: `docs/plans/2026-05-05-001-feat-swift-e2e-coverage-plan.md` +- Create or modify: `todos/` entries only for accepted deferred gaps. + +**Approach:** +- Build a simple feature-to-test matrix covering app shell, auth, packs, trips, templates, weather, catalog, chat, feed, guides, gear inventory, wildlife, shopping list, trail conditions, profile/preferences, and macOS windows. +- Mark each surface as unit-covered, iOS-E2E-covered, macOS-E2E-covered, deferred, or blocked. +- For every deferred gap, record why it is not covered now and what would unblock it. +- Re-fetch/rebase the worktree before final verification to catch other agents’ pushed changes. + +**Patterns to follow:** +- Current plan status/checklist format under `docs/plans/`. +- Existing todo conventions if todos are needed. + +**Test scenarios:** +- Test expectation: none -- this unit is documentation and review of completed behavioral coverage. + +**Verification:** +- Coverage matrix exists and matches the actual test files. +- Remaining gaps are explicit rather than hidden behind a broad “E2E covered” claim. + +## System-Wide Impact + +- **Interaction graph:** Auth state, API base URL, keychain storage, SwiftData persistence, generated Xcode schemes, iOS tab navigation, macOS sidebar/windows, and backend E2E data all interact with test reliability. +- **Error propagation:** Runner failures should fail fast with command/env details; UI failures should preserve screenshots/result bundles; app errors should surface visible user-safe messages that tests can assert. +- **State lifecycle risks:** Shared E2E account data, keychain tokens, saved UserDefaults/SwiftData state, and backend-created entities can leak between tests unless reset or uniquely named. +- **API surface parity:** Swift tests should cover native behavior; existing Expo Maestro tests remain separate coverage for the Expo client. +- **Integration coverage:** Unit tests alone will not prove login, navigation, backend CRUD, streaming chat, weather search, or macOS window behavior. Those need UI/E2E coverage. +- **Unchanged invariants:** XcodeGen remains the source of truth; generated `.xcodeproj` remains ignored; existing Expo scripts and Maestro flows continue to run independently. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Other agents push new Swift test or app changes mid-work | Fetch before creating the worktree, inspect upstream before editing shared files, and re-fetch/rebase before final verification. | +| E2E account accumulates stale data | Use run-scoped names, cleanup through UI where supported, and avoid assertions that require a blank account. | +| macOS UI automation behaves differently from iOS | Give macOS its own base test class and start with launch/navigation/settings/window smoke coverage. | +| macOS automation or signing prerequisites differ by machine | Detect prerequisites during the first smoke run, document local setup, and keep CI macOS E2E optional until the runner environment is proven. | +| Generated Xcode project churn creates noisy diffs | Change `project.yml`, regenerate locally, and keep generated project ignored. | +| Live backend instability causes false negatives | Keep unit tests network-free, make live E2E failures artifact-rich, and document backend/env failures separately from app regressions. | +| Credentials missing locally or in CI | Runner validates credentials before UI tests and prints a specific setup message without logging secret values. | +| Credentials leak through ephemeral scheme mutation or artifacts | Keep scheme mutation generated and uncommitted, redact runner output, and avoid storing credential values in result bundle metadata. | + +## Documentation / Operational Notes + +- Add `apps/swift/README.md` with the supported local commands, required `E2E_EMAIL`/`E2E_PASSWORD`, generated project expectations, test modes, and artifact locations. +- Update workflow docs to distinguish Swift XCTest/XCUITest from Expo Maestro E2E. +- Keep branch coordination notes short and practical: fetch/rebase often, avoid editing generated project files, and inspect latest upstream changes before modifying shared test files. + +## Sources & References + +- Existing Swift project plan: `docs/plans/2026-05-02-001-refactor-swift-xcodegen-multiplatform-plan.md` +- Swift parity plan: `docs/plans/2026-05-02-002-feat-swift-expo-parity-plan.md` +- XcodeGen project: `apps/swift/project.yml` +- Swift E2E runner: `apps/swift/scripts/run-e2e.ts` +- iOS UI test base: `apps/swift/Tests/PackRatUITests/AppUITestCase.swift` +- Unit test suites: `apps/swift/Tests/PackRatTests/` +- UI test suites: `apps/swift/Tests/PackRatUITests/` diff --git a/package.json b/package.json index 68fea6fcc5..d9286b862a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,11 @@ "clean": "bun run .github/scripts/clean.ts", "configure:deps": "bun run .github/scripts/configure-deps.ts", "e2e:swift": "bun run apps/swift/scripts/run-e2e.ts", + "e2e:swift:ios": "bun run apps/swift/scripts/run-e2e.ts ios-ui", + "e2e:swift:ios-smoke": "bun run apps/swift/scripts/run-e2e.ts ios-smoke", + "e2e:swift:mac": "bun run apps/swift/scripts/run-e2e.ts mac-build", + "e2e:swift:mac-smoke": "bun run apps/swift/scripts/run-e2e.ts mac-smoke", + "e2e:swift:mac-ui": "bun run apps/swift/scripts/run-e2e.ts mac-ui", "e2e:swift:macos": "bun run apps/swift/scripts/run-e2e-macos.ts", "env": "bun run .github/scripts/env.ts", "expo": "cd apps/expo && bun start", @@ -67,7 +72,9 @@ "test:landing": "vitest run --config apps/landing/vitest.config.ts", "test:mcp": "bun run --cwd packages/mcp test", "test:scripts": "vitest run --config scripts/vitest.config.ts", + "test:swift:runner": "bun test apps/swift/scripts/run-e2e.test.ts", "test:swift:scripts": "vitest run --config apps/swift/vitest.config.ts", + "test:swift:unit": "bun run apps/swift/scripts/run-e2e.ts unit", "trails": "bun run --cwd apps/trails dev", "web": "bun run --cwd apps/web dev", "web:screenshots": "bun run --cwd apps/expo screenshots:web" diff --git a/packages/api/drizzle/0037_big_archangel.sql b/packages/api/drizzle/0037_big_archangel.sql new file mode 100644 index 0000000000..dc79f87d61 --- /dev/null +++ b/packages/api/drizzle/0037_big_archangel.sql @@ -0,0 +1,43 @@ +CREATE TABLE "comment_likes" ( + "id" serial PRIMARY KEY NOT NULL, + "comment_id" integer NOT NULL, + "user_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "comment_likes_comment_id_user_id_unique" UNIQUE("comment_id","user_id") +); +--> statement-breakpoint +CREATE TABLE "post_comments" ( + "id" serial PRIMARY KEY NOT NULL, + "post_id" integer NOT NULL, + "user_id" integer NOT NULL, + "content" text NOT NULL, + "parent_comment_id" integer, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "post_likes" ( + "id" serial PRIMARY KEY NOT NULL, + "post_id" integer NOT NULL, + "user_id" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "post_likes_post_id_user_id_unique" UNIQUE("post_id","user_id") +); +--> statement-breakpoint +CREATE TABLE "posts" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "caption" text, + "images" jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_comment_id_post_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."post_comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comment_likes" ADD CONSTRAINT "comment_likes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_comments" ADD CONSTRAINT "post_comments_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_comments" ADD CONSTRAINT "post_comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_comments" ADD CONSTRAINT "post_comments_parent_comment_id_post_comments_id_fk" FOREIGN KEY ("parent_comment_id") REFERENCES "public"."post_comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_likes" ADD CONSTRAINT "post_likes_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "post_likes" ADD CONSTRAINT "post_likes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 380692a8b3..fcce3f5ffc 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -156,35 +156,36 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) '/', async ({ body, user }) => { const db = createDb(); - const data = body; + try { + const data = body; - // Zod validates all fields at runtime; cast through the Standard Schema - // inference gap so drizzle's insert accepts the values. - const [newPack] = await db - .tag('packs.create') - .insert(packs) - .values({ - id: data.id, - userId: user.userId, - name: data.name, - description: data.description, - // packs.category is notNull in the DB; the request schema makes it - // optional so clients without a category picker (or with no - // selection) still validate. Default to 'custom' here so the insert - // doesn't violate the NOT NULL constraint. - category: data.category ?? 'custom', - isPublic: data.isPublic ?? false, - image: data.image, - tags: data.tags, - localCreatedAt: new Date(data.localCreatedAt as string), - localUpdatedAt: new Date(data.localUpdatedAt as string), - } as typeof packs.$inferInsert) - .returning(); + // Zod validates all fields at runtime; cast through the Standard Schema + // inference gap so drizzle's insert accepts the values. + const [newPack] = await db + .tag('packs.create') + .insert(packs) + .values({ + id: data.id, + userId: user.userId, + name: data.name, + description: data.description ?? null, + category: data.category, + isPublic: data.isPublic ?? false, + image: data.image ?? null, + tags: data.tags ?? null, + localCreatedAt: new Date(data.localCreatedAt as string), + localUpdatedAt: new Date(data.localUpdatedAt as string), + } as typeof packs.$inferInsert) + .returning(); - if (!newPack) return status(500, { error: 'Failed to create pack' }); + if (!newPack) return status(500, { error: 'Failed to create pack' }); - const packWithItems: PackWithItems = { ...newPack, items: [] }; - return PackWithWeightsSchema.parse(computePackWeights({ pack: packWithItems })); + const packWithItems: PackWithItems = { ...newPack, items: [] }; + return PackWithWeightsSchema.parse(computePackWeights({ pack: packWithItems })); + } catch (error) { + captureApiException({ error, operation: 'packs.create' }); + return status(500, { error: 'Failed to create pack' }); + } }, { body: 'packs.CreatePackBody', diff --git a/packages/api/test/packs.test.ts b/packages/api/test/packs.test.ts index e3317ae39e..3e50d2389f 100644 --- a/packages/api/test/packs.test.ts +++ b/packages/api/test/packs.test.ts @@ -179,6 +179,45 @@ describe('Packs Routes', () => { expect(data.id).toBeDefined(); }); + it('defaults missing category to custom', async () => { + const newPack = { + id: `pack_test_no_category_${Date.now()}`, + name: 'Swift Created Pack', + isPublic: false, + localCreatedAt: new Date().toISOString(), + localUpdatedAt: new Date().toISOString(), + }; + + const res = await apiWithAuth('/packs', httpMethods.post(newPack)); + + expect([200, 201]).toContain(res.status); + const data = await expectJsonResponse(res, ['id', 'category']); + expect(data.id).toBe(newPack.id); + expect(data.category).toBe('custom'); + }); + + it('normalizes nullable native-client fields', async () => { + const newPack = { + id: `pack_test_null_category_${Date.now()}`, + name: 'Swift Nullable Pack', + description: null, + category: null, + tags: null, + isPublic: false, + localCreatedAt: new Date().toISOString(), + localUpdatedAt: new Date().toISOString(), + }; + + const res = await apiWithAuth('/packs', httpMethods.post(newPack)); + + expect([200, 201]).toContain(res.status); + const data = await expectJsonResponse(res, ['id', 'category']); + expect(data.id).toBe(newPack.id); + expect(data.description).toBeNull(); + expect(data.category).toBe('custom'); + expect(data.tags).toBeNull(); + }); + it('validates required fields', async () => { const res = await apiWithAuth('/packs', httpMethods.post({})); expectBadRequest(res); diff --git a/packages/app/src/features/pack/create/queries.ts b/packages/app/src/features/pack/create/queries.ts index fc45e84680..6429c03080 100644 --- a/packages/app/src/features/pack/create/queries.ts +++ b/packages/app/src/features/pack/create/queries.ts @@ -1,3 +1,4 @@ +import type { PackCategory } from '@packrat/constants'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { queryKeys, useApiClient } from '../../../shared/api'; import { generateId } from '../../../shared/lib/uuid'; @@ -5,7 +6,7 @@ import { generateId } from '../../../shared/lib/uuid'; interface CreatePackInput { name: string; description?: string; - category?: string; + category?: PackCategory; isPublic?: boolean; image?: string | null; tags?: string[]; @@ -20,6 +21,7 @@ export function useCreatePackMutation() { const { data, error } = await client.packs.post({ ...input, id: generateId(), + category: input.category ?? 'custom', isPublic: input.isPublic ?? false, localCreatedAt: now, localUpdatedAt: now, diff --git a/packages/cli/src/commands/packs/create.ts b/packages/cli/src/commands/packs/create.ts index e3032ae3a3..3c3d728d44 100644 --- a/packages/cli/src/commands/packs/create.ts +++ b/packages/cli/src/commands/packs/create.ts @@ -1,4 +1,5 @@ import { toRecord } from '@packrat/guards'; +import { PackCategorySchema } from '@packrat/schemas/constants'; import { defineCommand } from 'citty'; import consola from 'consola'; import { getUserClient } from '../../api/client'; @@ -13,7 +14,7 @@ export default defineCommand({ type: 'string', alias: 'c', description: 'Pack category (backpacking, camping, hiking, ...)', - default: 'general', + default: 'custom', }, description: { type: 'string', alias: 'd', description: 'Optional description' }, public: { type: 'boolean', description: 'Make pack public', default: false }, @@ -23,6 +24,7 @@ export default defineCommand({ await requireAuth(); const client = await getUserClient(); const now = nowIso(); + const category = PackCategorySchema.parse(args.category); const tags = args.tags ? args.tags .split(',') @@ -34,7 +36,7 @@ export default defineCommand({ id: shortId('p'), name: args.name, description: args.description, - category: args.category, + category, isPublic: args.public, tags, localCreatedAt: now, diff --git a/packages/schemas/src/packs.ts b/packages/schemas/src/packs.ts index d930914dd1..2f9ddb70fb 100644 --- a/packages/schemas/src/packs.ts +++ b/packages/schemas/src/packs.ts @@ -2,6 +2,8 @@ import { PACK_CATEGORIES, WEIGHT_UNITS } from '@packrat/constants'; import { z } from 'zod'; import { datetimeString } from './utils'; +export const DEFAULT_PACK_CATEGORY = 'custom' satisfies (typeof PACK_CATEGORIES)[number]; + export const PackItemSchema = z.object({ id: z.string(), name: z.string(), @@ -59,11 +61,16 @@ export type PackWithWeights = z.infer; export const CreatePackRequestSchema = z.object({ name: z.string().min(1).max(255), - description: z.string().optional(), - category: z.string().optional(), - isPublic: z.boolean().optional(), + description: z.string().nullish(), + category: z + .preprocess( + (value) => (value === null || value === '' ? undefined : value), + z.enum(PACK_CATEGORIES).optional(), + ) + .default(DEFAULT_PACK_CATEGORY), + isPublic: z.boolean().optional().default(false), image: z.string().nullish(), - tags: z.array(z.string()).optional(), + tags: z.array(z.string()).nullish(), }); export const UpdatePackRequestSchema = z.object({