From ba2e5e68f6fe0e14ecf09bbd16cbc3ff34b4a88e Mon Sep 17 00:00:00 2001 From: Andrew Bierman Date: Tue, 12 May 2026 08:17:45 -0600 Subject: [PATCH] feat: add Swift E2E coverage --- .github/actionlint.yaml | 21 + .github/workflows/swift-e2e.yml | 246 ++ .gitignore | 1 + apps/swift/README.md | 105 + .../Features/Catalog/CatalogView.swift | 2 + .../PackRat/Features/Chat/ChatView.swift | 7 + .../Features/Feed/ComposePostView.swift | 1 + .../PackRat/Features/Feed/FeedView.swift | 1 + .../PackRat/Features/Home/HomeView.swift | 12 +- .../PackTemplates/PackTemplateFormView.swift | 1 + .../PackTemplates/PackTemplatesView.swift | 1 + .../PackRat/Features/Packs/PackFormView.swift | 1 + .../Features/Packs/PackItemFormView.swift | 167 +- .../Features/Packs/PacksListView.swift | 1 + .../Features/Shopping/ShoppingListView.swift | 11 + .../TrailConditions/TrailConditionsView.swift | 3 + .../PackRat/Features/Trips/TripFormView.swift | 1 + .../Features/Trips/TripsListView.swift | 1 + .../Weather/WeatherAlertPreferencesView.swift | 2 + .../Features/Weather/WeatherView.swift | 2 + .../PackRat/Navigation/AppNavigation.swift | 37 +- .../Sources/PackRat/Network/APIClient.swift | 3 + apps/swift/Sources/PackRat/PackRatApp.swift | 39 + .../MacHomeFeatureTests.swift | 56 + .../MacNavigationTests.swift | 24 + .../PackRatMacUITests/MacPackTripTests.swift | 77 + .../MacSecondaryFeatureTests.swift | 116 + .../PackRatMacUITests/MacSmokeTests.swift | 52 + .../PackRatMacUITests/MacUITestCase.swift | 164 ++ .../PackRatMacUITests/MacWeatherTests.swift | 49 + .../Tests/PackRatUITests/AppUITestCase.swift | 22 + .../Tests/PackRatUITests/AuthTests.swift | 3 + .../Tests/PackRatUITests/HomeTileTests.swift | 121 + .../PackRatUITests/PackSubFlowTests.swift | 10 + .../PackRatUITests/ScreenshotSmokeTests.swift | 43 + apps/swift/project.yml | 23 +- apps/swift/scripts/run-e2e.test.ts | 156 ++ apps/swift/scripts/run-e2e.ts | 457 +++- docs/ci/swift-e2e-runner.md | 106 + ...-05-05-001-feat-swift-e2e-coverage-plan.md | 430 ++++ package.json | 9 +- packages/api/drizzle/0037_big_archangel.sql | 43 + packages/api/drizzle/meta/0037_snapshot.json | 2070 +++++++++++++++++ packages/api/drizzle/meta/_journal.json | 7 + packages/api/src/routes/packs/index.ts | 55 +- packages/api/src/schemas/packs.ts | 13 +- packages/api/test/packs.test.ts | 39 + 47 files changed, 4659 insertions(+), 152 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/workflows/swift-e2e.yml create mode 100644 apps/swift/README.md create mode 100644 apps/swift/Tests/PackRatMacUITests/MacHomeFeatureTests.swift create mode 100644 apps/swift/Tests/PackRatMacUITests/MacNavigationTests.swift create mode 100644 apps/swift/Tests/PackRatMacUITests/MacPackTripTests.swift create mode 100644 apps/swift/Tests/PackRatMacUITests/MacSecondaryFeatureTests.swift create mode 100644 apps/swift/Tests/PackRatMacUITests/MacSmokeTests.swift create mode 100644 apps/swift/Tests/PackRatMacUITests/MacUITestCase.swift create mode 100644 apps/swift/Tests/PackRatMacUITests/MacWeatherTests.swift create mode 100644 apps/swift/Tests/PackRatUITests/HomeTileTests.swift create mode 100644 apps/swift/Tests/PackRatUITests/ScreenshotSmokeTests.swift create mode 100644 apps/swift/scripts/run-e2e.test.ts create mode 100644 docs/ci/swift-e2e-runner.md create mode 100644 docs/plans/2026-05-05-001-feat-swift-e2e-coverage-plan.md create mode 100644 packages/api/drizzle/0037_big_archangel.sql create mode 100644 packages/api/drizzle/meta/0037_snapshot.json 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..f6f0100ee5 --- /dev/null +++ b/.github/workflows/swift-e2e.yml @@ -0,0 +1,246 @@ +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) || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + + 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/.gitignore b/.gitignore index a3ff9e3c99..b14365e294 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ apps/swift/xcconfig/*.local.xcconfig apps/swift/PackRat.xcodeproj/ apps/swift/*.xcworkspace/xcuserdata/ apps/swift/DerivedData/ +apps/swift/TestResults/ apps/swift/.build/ apps/swift/.swiftpm/ apps/swift/Package.resolved 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/Catalog/CatalogView.swift b/apps/swift/Sources/PackRat/Features/Catalog/CatalogView.swift index 16901bcc81..e98777ace9 100644 --- a/apps/swift/Sources/PackRat/Features/Catalog/CatalogView.swift +++ b/apps/swift/Sources/PackRat/Features/Catalog/CatalogView.swift @@ -39,6 +39,7 @@ struct CatalogView: View { TextField("Search tents, packs, sleeping bags…", text: $bvm.searchText) .onChange(of: vm.searchText) { vm.onSearchTextChanged() } .onSubmit { Task { await vm.search(reset: true) } } + .accessibilityIdentifier("catalog_search") if vm.isLoading { ProgressView().controlSize(.small) } else if !vm.searchText.isEmpty { @@ -46,6 +47,7 @@ struct CatalogView: View { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) } .buttonStyle(.plain) + .accessibilityIdentifier("catalog_search_clear") } } .padding(10) diff --git a/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift b/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift index 5db23edf7c..93b2f9577a 100644 --- a/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift +++ b/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift @@ -118,12 +118,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/ComposePostView.swift b/apps/swift/Sources/PackRat/Features/Feed/ComposePostView.swift index b01a9fe8d3..6aa3ecb379 100644 --- a/apps/swift/Sources/PackRat/Features/Feed/ComposePostView.swift +++ b/apps/swift/Sources/PackRat/Features/Feed/ComposePostView.swift @@ -46,6 +46,7 @@ struct ComposePostView: View { Text("\(caption.count) / 500") .font(.caption) .foregroundStyle(caption.count > 450 ? .orange : .secondary) + .accessibilityIdentifier("feed_compose_counter") Spacer() } .padding(.horizontal) diff --git a/apps/swift/Sources/PackRat/Features/Feed/FeedView.swift b/apps/swift/Sources/PackRat/Features/Feed/FeedView.swift index fdde93343b..8d5d529113 100644 --- a/apps/swift/Sources/PackRat/Features/Feed/FeedView.swift +++ b/apps/swift/Sources/PackRat/Features/Feed/FeedView.swift @@ -42,6 +42,7 @@ struct FeedView: View { showingCompose = true } .keyboardShortcut("n", modifiers: .command) + .accessibilityIdentifier("new_post_button") } } .task { if viewModel.posts.isEmpty { await viewModel.load() } } diff --git a/apps/swift/Sources/PackRat/Features/Home/HomeView.swift b/apps/swift/Sources/PackRat/Features/Home/HomeView.swift index b448a5c9ef..14b7587ac3 100644 --- a/apps/swift/Sources/PackRat/Features/Home/HomeView.swift +++ b/apps/swift/Sources/PackRat/Features/Home/HomeView.swift @@ -120,7 +120,7 @@ struct HomeView: View { HomeTileCard( title: "AI Assistant", subtitle: "Ask about gear & trips", - symbol: "bubble.left.and.sparkles", + symbol: "bubble.left", color: .purple ) { appState.navItem = .chat } @@ -248,5 +248,15 @@ struct HomeTileCard: View { ) } .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityID) + } + + private var accessibilityID: String { + let slug = title + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "_") + return "home_tile_\(slug)" } } diff --git a/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplateFormView.swift b/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplateFormView.swift index 6807a9cd17..da966aa5b9 100644 --- a/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplateFormView.swift +++ b/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplateFormView.swift @@ -33,6 +33,7 @@ struct PackTemplateFormView: View { Form { Section("Template Info") { TextField("Name", text: $name) + .accessibilityIdentifier("template_name") TextField("Description (optional)", text: $description, axis: .vertical) .lineLimit(2...4) } diff --git a/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplatesView.swift b/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplatesView.swift index 5c1a4ec841..a30606acb6 100644 --- a/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplatesView.swift +++ b/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplatesView.swift @@ -40,6 +40,7 @@ struct PackTemplatesListView: View { Button("New Template", systemImage: "plus") { showingNewTemplate = true } + .accessibilityIdentifier("new_template_button") } } .sheet(isPresented: $showingNewTemplate) { diff --git a/apps/swift/Sources/PackRat/Features/Packs/PackFormView.swift b/apps/swift/Sources/PackRat/Features/Packs/PackFormView.swift index 9c11c5d0c8..d927e03eb2 100644 --- a/apps/swift/Sources/PackRat/Features/Packs/PackFormView.swift +++ b/apps/swift/Sources/PackRat/Features/Packs/PackFormView.swift @@ -26,6 +26,7 @@ struct PackFormView: View { Form { Section("Details") { TextField("Pack Name", text: $name) + .accessibilityIdentifier("pack_name") TextField("Description (optional)", text: $description, axis: .vertical) .lineLimit(3, reservesSpace: true) } diff --git a/apps/swift/Sources/PackRat/Features/Packs/PackItemFormView.swift b/apps/swift/Sources/PackRat/Features/Packs/PackItemFormView.swift index 0e832343f5..6ed6e5db15 100644 --- a/apps/swift/Sources/PackRat/Features/Packs/PackItemFormView.swift +++ b/apps/swift/Sources/PackRat/Features/Packs/PackItemFormView.swift @@ -29,85 +29,162 @@ struct PackItemFormView: View { var body: some View { NavigationStack { - Form { - Section("Item") { + Group { + #if os(macOS) + macForm + #else + Form { + Section("Item") { + TextField("Name", text: $name) + .accessibilityIdentifier("item_name") + } + + Section("Weight") { + HStack { + TextField("0", text: $weightText) + .keyboardType(.decimalPad) + .accessibilityIdentifier("item_weight") + Picker("Unit", selection: $weightUnit) { + ForEach(AppWeightUnit.allCases, id: \.rawValue) { u in + Text(u.label).tag(u.rawValue) + } + } + .labelsHidden() + .frame(width: 60) + } + } + + Section("Quantity & Category") { + HStack { + Text("Quantity") + Spacer() + TextField("1", text: $quantityText) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(width: 60) + } + Picker("Category", selection: $category) { + Text("None").tag("") + ForEach(PackCategory.allCases, id: \.rawValue) { cat in + Label(cat.label, systemImage: cat.symbol).tag(cat.rawValue) + } + } + } + + Section("Flags") { + Toggle("Consumable", isOn: $consumable) + Toggle("Worn on body", isOn: $worn) + } + + Section("Notes") { + TextField("Optional notes", text: $notes, axis: .vertical) + .lineLimit(3, reservesSpace: true) + } + + if let error { + Section { + InlineErrorView(message: error) + } + } + } + #endif + } + .navigationTitle(isEditing ? "Edit Item" : "Add Item") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isEditing ? "Save" : "Add") { submit() } + .disabled(!isValid || isLoading) + } + } + .onAppear { prefill() } + } + #if os(macOS) + .frame(minWidth: 400, minHeight: 350) + #endif + } + + #if os(macOS) + private var macForm: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .font(.caption) + .foregroundStyle(.secondary) TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("item_name") } - Section("Weight") { - HStack { + HStack(alignment: .bottom, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("Weight") + .font(.caption) + .foregroundStyle(.secondary) TextField("0", text: $weightText) - #if os(iOS) - .keyboardType(.decimalPad) - #endif + .textFieldStyle(.roundedBorder) + .frame(width: 120) .accessibilityIdentifier("item_weight") - Picker("Unit", selection: $weightUnit) { - ForEach(AppWeightUnit.allCases, id: \.rawValue) { u in - Text(u.label).tag(u.rawValue) - } + } + + Picker("Unit", selection: $weightUnit) { + ForEach(AppWeightUnit.allCases, id: \.rawValue) { u in + Text(u.label).tag(u.rawValue) } - .labelsHidden() - .frame(width: 60) } + .pickerStyle(.menu) + .frame(width: 90) } - Section("Quantity & Category") { - HStack { + HStack(alignment: .bottom, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { Text("Quantity") - Spacer() + .font(.caption) + .foregroundStyle(.secondary) TextField("1", text: $quantityText) - #if os(iOS) - .keyboardType(.numberPad) - #endif - .multilineTextAlignment(.trailing) - .frame(width: 60) + .textFieldStyle(.roundedBorder) + .frame(width: 120) } + Picker("Category", selection: $category) { Text("None").tag("") ForEach(PackCategory.allCases, id: \.rawValue) { cat in Label(cat.label, systemImage: cat.symbol).tag(cat.rawValue) } } - #if os(macOS) .pickerStyle(.menu) - #endif + .frame(maxWidth: .infinity, alignment: .leading) } - Section("Flags") { + VStack(alignment: .leading, spacing: 8) { Toggle("Consumable", isOn: $consumable) Toggle("Worn on body", isOn: $worn) } - Section("Notes") { + VStack(alignment: .leading, spacing: 6) { + Text("Notes") + .font(.caption) + .foregroundStyle(.secondary) TextField("Optional notes", text: $notes, axis: .vertical) + .textFieldStyle(.roundedBorder) .lineLimit(3, reservesSpace: true) } if let error { - Section { - InlineErrorView(message: error) - } - } - } - .navigationTitle(isEditing ? "Edit Item" : "Add Item") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button(isEditing ? "Save" : "Add") { submit() } - .disabled(!isValid || isLoading) + InlineErrorView(message: error) } } - .onAppear { prefill() } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) } - #if os(macOS) - .frame(minWidth: 400, minHeight: 350) - #endif } + #endif private func prefill() { guard let item = existingItem else { return } diff --git a/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift b/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift index 9ca7f455ac..00765baa35 100644 --- a/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift +++ b/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift @@ -60,6 +60,7 @@ struct PacksListView: View { if !isExplore { Button("New Pack", systemImage: "plus") { showingCreateSheet = true } .keyboardShortcut("n", modifiers: .command) + .accessibilityIdentifier("new_pack_button") } if viewModel.isLoading || isLoadingPublic { ProgressView().controlSize(.small) diff --git a/apps/swift/Sources/PackRat/Features/Shopping/ShoppingListView.swift b/apps/swift/Sources/PackRat/Features/Shopping/ShoppingListView.swift index bbd0d71a8a..f30f9e9005 100644 --- a/apps/swift/Sources/PackRat/Features/Shopping/ShoppingListView.swift +++ b/apps/swift/Sources/PackRat/Features/Shopping/ShoppingListView.swift @@ -68,21 +68,25 @@ struct ShoppingListView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } + .accessibilityIdentifier("shopping_done") } ToolbarItem(placement: .primaryAction) { Button { showingAddSheet = true } label: { Image(systemName: "plus") } + .accessibilityIdentifier("shopping_add_item") } if !items.isEmpty { ToolbarItem(placement: .secondaryAction) { Button(showPurchased ? "Hide Purchased" : "Show Purchased") { withAnimation { showPurchased.toggle() } } + .accessibilityIdentifier("shopping_toggle_purchased_visibility") } if items.contains(where: { $0.isPurchased }) { ToolbarItem(placement: .secondaryAction) { Button("Clear Purchased", role: .destructive) { clearPurchased() } + .accessibilityIdentifier("shopping_clear_purchased") } } } @@ -131,6 +135,7 @@ private struct ShoppingItemRow: View { .foregroundStyle(item.isPurchased ? .green : .secondary) } .buttonStyle(.plain) + .accessibilityIdentifier("shopping_toggle_\(item.id)") VStack(alignment: .leading, spacing: 3) { Text(item.name) @@ -181,20 +186,24 @@ private struct AddShoppingItemSheet: View { Form { Section("Item") { TextField("Name (required)", text: $name) + .accessibilityIdentifier("shopping_item_name") Picker("Category", selection: $category) { Text("None").tag("") ForEach(categories, id: \.self) { cat in Text(cat).tag(cat) } } + .accessibilityIdentifier("shopping_item_category") } Section("Details") { TextField("Estimated price ($)", text: $priceText) + .accessibilityIdentifier("shopping_item_price") #if os(iOS) .keyboardType(.decimalPad) #endif TextField("Notes", text: $notes, axis: .vertical) .lineLimit(3) + .accessibilityIdentifier("shopping_item_notes") } } .navigationTitle("Add Item") @@ -204,10 +213,12 @@ private struct AddShoppingItemSheet: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } + .accessibilityIdentifier("shopping_item_cancel") } ToolbarItem(placement: .confirmationAction) { Button("Add") { save() } .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .accessibilityIdentifier("shopping_item_add") } } } diff --git a/apps/swift/Sources/PackRat/Features/TrailConditions/TrailConditionsView.swift b/apps/swift/Sources/PackRat/Features/TrailConditions/TrailConditionsView.swift index a707f91ac5..edb7d7ef82 100644 --- a/apps/swift/Sources/PackRat/Features/TrailConditions/TrailConditionsView.swift +++ b/apps/swift/Sources/PackRat/Features/TrailConditions/TrailConditionsView.swift @@ -36,6 +36,7 @@ struct TrailConditionsListView: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button("Submit Report", systemImage: "plus") { showingSubmitSheet = true } + .accessibilityIdentifier("trail_submit_report_toolbar") } } .task { if viewModel.reports.isEmpty { await viewModel.load() } } @@ -237,6 +238,7 @@ struct SubmitTrailConditionView: View { Form { Section("Trail") { TextField("Trail Name", text: $trailName) + .accessibilityIdentifier("trail_name") TextField("Region / Area (optional)", text: $trailRegion) } Section("Conditions") { @@ -258,6 +260,7 @@ struct SubmitTrailConditionView: View { get: { selectedHazards.contains(hazard) }, set: { on in if on { selectedHazards.insert(hazard) } else { selectedHazards.remove(hazard) } } )) + .accessibilityIdentifier("trail_hazard_\(hazard.replacingOccurrences(of: " ", with: "_"))") } } Section("Notes") { diff --git a/apps/swift/Sources/PackRat/Features/Trips/TripFormView.swift b/apps/swift/Sources/PackRat/Features/Trips/TripFormView.swift index f77efd5f27..69e79e1653 100644 --- a/apps/swift/Sources/PackRat/Features/Trips/TripFormView.swift +++ b/apps/swift/Sources/PackRat/Features/Trips/TripFormView.swift @@ -38,6 +38,7 @@ struct TripFormView: View { Form { Section("Details") { TextField("Trip Name", text: $name) + .accessibilityIdentifier("trip_name") TextField("Description (optional)", text: $description, axis: .vertical) .lineLimit(3, reservesSpace: true) } diff --git a/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift b/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift index 321cda6a33..2d67a63a74 100644 --- a/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift +++ b/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift @@ -38,6 +38,7 @@ struct TripsListView: View { ToolbarItem(placement: .primaryAction) { Button("Plan Trip", systemImage: "plus") { showingCreateSheet = true } .keyboardShortcut("n", modifiers: [.command, .shift]) + .accessibilityIdentifier("plan_trip_button") } } .task { await viewModel.load(context: modelContext) } diff --git a/apps/swift/Sources/PackRat/Features/Weather/WeatherAlertPreferencesView.swift b/apps/swift/Sources/PackRat/Features/Weather/WeatherAlertPreferencesView.swift index 4d6e0066ea..c8cc55af93 100644 --- a/apps/swift/Sources/PackRat/Features/Weather/WeatherAlertPreferencesView.swift +++ b/apps/swift/Sources/PackRat/Features/Weather/WeatherAlertPreferencesView.swift @@ -16,6 +16,7 @@ struct WeatherAlertPreferencesView: View { Form { Section("General") { Toggle("Weather Notifications", isOn: $weatherNotifications) + .accessibilityIdentifier("weather_notifications_toggle") Toggle("Location Monitoring", isOn: $locationMonitoring) } @@ -76,6 +77,7 @@ struct WeatherAlertPreferencesView: View { .foregroundStyle(.teal) } } + .accessibilityIdentifier("high_winds_toggle") Toggle(isOn: $fogAlerts) { Label { Text("Fog Alerts") diff --git a/apps/swift/Sources/PackRat/Features/Weather/WeatherView.swift b/apps/swift/Sources/PackRat/Features/Weather/WeatherView.swift index 8bdfd8e6cf..2b84af6d54 100644 --- a/apps/swift/Sources/PackRat/Features/Weather/WeatherView.swift +++ b/apps/swift/Sources/PackRat/Features/Weather/WeatherView.swift @@ -59,6 +59,7 @@ struct WeatherView: View { } label: { Label("Alert Preferences", systemImage: "slider.horizontal.3") } + .accessibilityIdentifier("weather_alert_preferences_button") } } .sheet(isPresented: $showingAlerts) { @@ -75,6 +76,7 @@ struct WeatherView: View { .foregroundStyle(.secondary) TextField("Search locations…", text: $viewModel.searchText) .onChange(of: viewModel.searchText) { viewModel.onSearchTextChanged() } + .accessibilityIdentifier("weather_location_search") if viewModel.isSearching { ProgressView().controlSize(.small) } else if !viewModel.searchText.isEmpty { diff --git a/apps/swift/Sources/PackRat/Navigation/AppNavigation.swift b/apps/swift/Sources/PackRat/Navigation/AppNavigation.swift index 7ee5af5b9d..b1eaa75ce5 100644 --- a/apps/swift/Sources/PackRat/Navigation/AppNavigation.swift +++ b/apps/swift/Sources/PackRat/Navigation/AppNavigation.swift @@ -29,7 +29,7 @@ enum NavItem: String, CaseIterable, Identifiable { case .packs: return "backpack" case .trips: return "map" case .weather: return "cloud.sun" - case .chat: return "bubble.left.and.sparkles" + case .chat: return "bubble.left" case .catalog: return "magnifyingglass" case .templates: return "doc.on.doc" case .trailConditions: return "figure.hiking" @@ -104,6 +104,23 @@ struct AppNavigation: View { private var sidebar: some View { @Bindable var state = appState + #if os(macOS) + return List(NavItem.allCases) { item in + Button { + state.navItem = item + } label: { + Label(item.label, systemImage: item.symbol) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .accessibilityIdentifier("sidebar_nav_\(item.rawValue)") + } + .navigationTitle("PackRat") + .navigationSplitViewColumnWidth(min: 160, ideal: 190) + .safeAreaInset(edge: .bottom) { + userFooter + } + #else let optionalNavItem = Binding( get: { state.navItem }, set: { state.navItem = $0 ?? .home } @@ -118,6 +135,7 @@ struct AppNavigation: View { .safeAreaInset(edge: .bottom) { userFooter } + #endif } @ViewBuilder @@ -127,28 +145,40 @@ struct AppNavigation: View { switch appState.navItem { case .home: HomeView().environment(appState) + .accessibilityIdentifier("screen_home") case .packs: PacksListView(viewModel: appState.packsVM, selectedId: $state.selectedPackId) + .accessibilityIdentifier("screen_packs") case .trips: TripsListView(viewModel: appState.tripsVM, selectedId: $state.selectedTripId) + .accessibilityIdentifier("screen_trips") case .templates: PackTemplatesListView(viewModel: appState.templatesVM, selectedId: $state.selectedTemplateId, packsVM: appState.packsVM) + .accessibilityIdentifier("screen_templates") case .trailConditions: TrailConditionsListView(viewModel: appState.trailConditionsVM, selectedId: $state.selectedReportId) + .accessibilityIdentifier("screen_trailConditions") case .weather: WeatherView(viewModel: appState.weatherVM) + .accessibilityIdentifier("screen_weather") case .catalog: CatalogView().environment(appState) + .accessibilityIdentifier("screen_catalog") case .chat: ChatView(viewModel: appState.chatVM) + .accessibilityIdentifier("screen_chat") case .feed: FeedView(viewModel: appState.feedVM) + .accessibilityIdentifier("screen_feed") case .guides: GuidesView() + .accessibilityIdentifier("screen_guides") case .gearInventory: GearInventoryView().environment(appState) + .accessibilityIdentifier("screen_gearInventory") case .wildlife: WildlifeView() + .accessibilityIdentifier("screen_wildlife") } } @@ -196,13 +226,16 @@ struct AppNavigation: View { #if os(iOS) private var phoneLayout: some View { - TabView { + @Bindable var state = appState + + return TabView(selection: $state.navItem) { ForEach(NavItem.allCases) { item in NavigationStack { phoneContentView(item) .navigationTitle(item.label) } .tabItem { Label(item.label, systemImage: item.symbol) } + .tag(item) } } .environment(appState) diff --git a/apps/swift/Sources/PackRat/Network/APIClient.swift b/apps/swift/Sources/PackRat/Network/APIClient.swift index 9209b83171..8856fedfbd 100644 --- a/apps/swift/Sources/PackRat/Network/APIClient.swift +++ b/apps/swift/Sources/PackRat/Network/APIClient.swift @@ -27,6 +27,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 ce1bd75fa8..ee34fca79d 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 + } var body: some Scene { WindowGroup { @@ -45,3 +60,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 ee99138d51..1e2a82f951 100644 --- a/apps/swift/Tests/PackRatUITests/AppUITestCase.swift +++ b/apps/swift/Tests/PackRatUITests/AppUITestCase.swift @@ -18,6 +18,9 @@ class AppUITestCase: XCTestCase { app = XCUIApplication() // Disable animations so tests don't have to wait for spring physics app.launchArguments.append("--disable-animations") + if let apiBaseURL = ProcessInfo.processInfo.environment["E2E_API_BASE_URL"], !apiBaseURL.isEmpty { + app.launchEnvironment["E2E_API_BASE_URL"] = apiBaseURL + } app.launch() try loginIfNeeded() } @@ -51,6 +54,7 @@ class AppUITestCase: XCTestCase { app.tabBars.firstMatch.waitForExistence(timeout: 20), "Tab bar must appear after login — check credentials or network" ) + dismissSystemPrompts(timeout: 2) } // MARK: - Navigation helpers @@ -58,6 +62,8 @@ class AppUITestCase: XCTestCase { /// Navigates to a tab by label. iOS shows the first 4 NavItems as tabs and /// the rest behind a "More" overflow tab — this helper handles both cases. func goToTab(_ label: String) { + dismissSystemPrompts(timeout: 0.1) + // Dismiss any active keyboard / search focus that could obstruct // tab bar interaction. if app.keyboards.firstMatch.exists { @@ -96,6 +102,22 @@ class AppUITestCase: XCTestCase { direct.tap() } + func dismissSystemPrompts(timeout: TimeInterval) { + let labels = ["Not Now", "Don’t Save", "Don't Save", "Cancel"] + let deadline = Date().addingTimeInterval(timeout) + + repeat { + for label in labels { + let button = app.buttons[label] + if button.exists { + button.tap() + return + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } while Date() < deadline + } + // MARK: - Wait helpers @discardableResult diff --git a/apps/swift/Tests/PackRatUITests/AuthTests.swift b/apps/swift/Tests/PackRatUITests/AuthTests.swift index 570b8f9fe5..da2012a25d 100644 --- a/apps/swift/Tests/PackRatUITests/AuthTests.swift +++ b/apps/swift/Tests/PackRatUITests/AuthTests.swift @@ -9,6 +9,9 @@ final class AuthTests: XCTestCase { app.launchArguments.append("--disable-animations") // Force logged-out state so the login screen is reachable. 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() } 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 0a597c5ded..ddeeb44b9d 100644 --- a/apps/swift/Tests/PackRatUITests/PackSubFlowTests.swift +++ b/apps/swift/Tests/PackRatUITests/PackSubFlowTests.swift @@ -123,6 +123,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/apps/swift/project.yml b/apps/swift/project.yml index c394ef850a..b5c2ffaa4a 100644 --- a/apps/swift/project.yml +++ b/apps/swift/project.yml @@ -98,7 +98,7 @@ targets: MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic - DEVELOPMENT_TEAM: 7WV9JYCW55 + DEVELOPMENT_TEAM: 666HGMV2LU PRODUCT_BUNDLE_IDENTIFIER: com.andrewbierman.packrat PRODUCT_MODULE_NAME: PackRat @@ -149,7 +149,7 @@ targets: MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic - DEVELOPMENT_TEAM: 7WV9JYCW55 + DEVELOPMENT_TEAM: 666HGMV2LU PRODUCT_BUNDLE_IDENTIFIER: com.andrewbierman.packrat.mac PackRatTests: @@ -176,6 +176,21 @@ targets: SWIFT_VERSION: "5.9" GENERATE_INFOPLIST_FILE: YES + PackRatMacUITests: + type: bundle.ui-testing + platform: macOS + sources: + - Tests/PackRatMacUITests + dependencies: + - target: PackRat-macOS + settings: + base: + SWIFT_VERSION: "5.9" + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: 666HGMV2LU + PRODUCT_BUNDLE_IDENTIFIER: com.andrewbierman.packrat.mac.uitests + schemes: PackRat-iOS: build: @@ -195,6 +210,10 @@ schemes: build: targets: PackRat-macOS: all + PackRatMacUITests: [test] + test: + targets: + - PackRatMacUITests run: config: Debug archive: diff --git a/apps/swift/scripts/run-e2e.test.ts b/apps/swift/scripts/run-e2e.test.ts new file mode 100644 index 0000000000..2a64069749 --- /dev/null +++ b/apps/swift/scripts/run-e2e.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + buildUITestEnv, + buildXcodeEnv, + loadDotEnv, + parseArgs, + pickIOSDestination, + redactSecrets, +} from './run-e2e'; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function tempFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), 'packrat-swift-e2e-')); + tempDirs.push(dir); + const path = join(dir, '.env.local'); + writeFileSync(path, contents); + return path; +} + +describe('parseArgs', () => { + test('defaults to ios-ui for legacy invocation', () => { + expect(parseArgs(['-only-testing:PackRatUITests/AuthTests']).mode).toBe('ios-ui'); + expect(parseArgs(['-only-testing:PackRatUITests/AuthTests']).passthrough).toEqual([ + '-only-testing:PackRatUITests/AuthTests', + ]); + }); + + test('accepts an explicit mode', () => { + expect(parseArgs(['unit'])).toEqual({ mode: 'unit', passthrough: [] }); + expect(parseArgs(['ios-smoke'])).toEqual({ mode: 'ios-smoke', passthrough: [] }); + expect(parseArgs(['mac-build', 'CODE_SIGNING_ALLOWED=NO'])).toEqual({ + mode: 'mac-build', + passthrough: ['CODE_SIGNING_ALLOWED=NO'], + }); + expect(parseArgs(['mac-smoke'])).toEqual({ mode: 'mac-smoke', passthrough: [] }); + expect(parseArgs(['mac-ui'])).toEqual({ mode: 'mac-ui', passthrough: [] }); + }); +}); + +describe('loadDotEnv', () => { + test('loads quoted values and preserves existing environment values', () => { + const env = { E2E_EMAIL: 'already-set@example.com' }; + loadDotEnv( + tempFile(` +E2E_EMAIL="from-file@example.com" +E2E_PASSWORD='secret-password' +EMPTY_LINE_TEST=value +`), + env, + ); + + expect(env.E2E_EMAIL).toBe('already-set@example.com'); + expect(env.E2E_PASSWORD).toBe('secret-password'); + expect(env.EMPTY_LINE_TEST).toBe('value'); + }); +}); + +describe('pickIOSDestination', () => { + test('uses a booted iPhone when one exists', () => { + const output = ` +== Devices == +-- iOS 26.2 -- + iPhone 17 (11111111-1111-1111-1111-111111111111) (Booted) +`; + + expect(pickIOSDestination(output)).toBe( + 'platform=iOS Simulator,id=11111111-1111-1111-1111-111111111111', + ); + }); + + test('prefers an available iPhone 17 over other shutdown phones', () => { + const output = ` +== Devices == +-- iOS 26.2 -- + iPhone 16e (22222222-2222-2222-2222-222222222222) (Shutdown) + iPhone 17 (33333333-3333-3333-3333-333333333333) (Shutdown) +`; + + expect(pickIOSDestination(output)).toBe( + 'platform=iOS Simulator,id=33333333-3333-3333-3333-333333333333', + ); + }); + + test('falls back to an iPhone 17 name when no devices parse', () => { + expect(pickIOSDestination('== Devices ==')).toBe('platform=iOS Simulator,name=iPhone 17'); + }); +}); + +describe('redactSecrets', () => { + test('redacts credential-like environment values', () => { + const env = { + E2E_EMAIL: 'person@example.com', + E2E_PASSWORD: 'super-secret', + NORMAL_VALUE: 'visible', + }; + + expect(redactSecrets('person@example.com super-secret visible', env)).toBe( + ' visible', + ); + }); +}); + +describe('buildUITestEnv', () => { + test('injects UI test environment with configured screenshot directory when set', () => { + expect( + buildUITestEnv({ + email: 'person@example.com', + password: 'secret', + env: { + E2E_API_BASE_URL: 'http://localhost:8788', + E2E_SCREENSHOT_DIR: '/tmp/packrat-screenshots', + }, + }), + ).toEqual({ + E2E_EMAIL: 'person@example.com', + E2E_PASSWORD: 'secret', + E2E_API_BASE_URL: 'http://localhost:8788', + E2E_SCREENSHOT_DIR: '/tmp/packrat-screenshots', + }); + }); + + test('defaults screenshot directory for visual smoke tests', () => { + expect(buildUITestEnv({ email: 'person@example.com', password: 'secret' })).toMatchObject({ + E2E_EMAIL: 'person@example.com', + E2E_PASSWORD: 'secret', + E2E_SCREENSHOT_DIR: expect.stringContaining('apps/swift/TestResults/screenshots'), + }); + }); +}); + +describe('buildXcodeEnv', () => { + test('keeps build essentials without forwarding secrets', () => { + expect( + buildXcodeEnv({ + HOME: '/Users/test', + PATH: '/usr/bin', + E2E_PASSWORD: 'secret', + OPENAI_API_KEY: 'secret', + }), + ).toEqual({ + HOME: '/Users/test', + PATH: '/usr/bin', + }); + }); +}); diff --git a/apps/swift/scripts/run-e2e.ts b/apps/swift/scripts/run-e2e.ts index 8a6afc6975..3d09ab89fe 100644 --- a/apps/swift/scripts/run-e2e.ts +++ b/apps/swift/scripts/run-e2e.ts @@ -1,47 +1,121 @@ #!/usr/bin/env bun -import { execSync, spawnSync } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + /** - * Run PackRat Swift XCUITests with credentials loaded from .env.local. + * Run PackRat Swift tests with credentials loaded from .env.local. * - * Usage: bun e2e:swift (run all UI tests) - * bun e2e:swift -only-testing: (run a specific test method) + * Usage: + * bun e2e:swift # iOS UI tests, matching old behavior + * bun e2e:swift unit # PackRatTests only + * bun e2e:swift ios-ui # PackRatUITests only + * bun e2e:swift ios-smoke # focused iOS UI smoke subset + * bun e2e:swift all # PackRatTests + PackRatUITests + * bun e2e:swift mac-build # macOS app compile check, signing disabled + * bun e2e:swift mac-smoke # focused macOS UI smoke subset + * bun e2e:swift mac-ui # full PackRatMacUITests suite + * bun e2e:swift ios-ui -only-testing:PackRatUITests/AuthTests/testLoginScreenAppears * - * Required env vars (in .env.local): - * E2E_EMAIL - * E2E_PASSWORD + * Required for UI modes: + * E2E_EMAIL or E2E_TEST_EMAIL + * E2E_PASSWORD or E2E_TEST_PASSWORD * - * How credentials reach the test runner: - * xcodebuild reads the scheme's TestAction EnvironmentVariables when - * launching XCTRunner. We inject E2E_EMAIL/E2E_PASSWORD into that block - * in the .xcscheme XML before invoking xcodebuild test. The scheme is - * regenerated from project.yml on every `bun swift`, so this edit is - * ephemeral and safe. + * Optional for UI modes: + * E2E_API_BASE_URL */ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; const REPO_ROOT = resolve(import.meta.dir, '../../..'); const SWIFT_DIR = resolve(REPO_ROOT, 'apps/swift'); -const SCHEME_PATH = resolve( +const IOS_SCHEME_PATH = resolve( SWIFT_DIR, 'PackRat.xcodeproj/xcshareddata/xcschemes/PackRat-iOS.xcscheme', ); +const MAC_SCHEME_PATH = resolve( + SWIFT_DIR, + 'PackRat.xcodeproj/xcshareddata/xcschemes/PackRat-macOS.xcscheme', +); +const TEST_RESULTS_DIR = resolve(SWIFT_DIR, 'TestResults'); const QUOTE_RE = /^["']|["']$/g; const ENV_BLOCK_RE = /\s*[\s\S]*?<\/EnvironmentVariables>/g; const TEST_ACTION_INHERIT_RE = /(]*?)shouldUseLaunchSchemeArgsEnv\s*=\s*"YES"/; -const SIMCTL_BOOTED_RE = /iPhone[^()]+\(([0-9A-F-]{36})\)/; +const BOOTED_IPHONE_RE = /iPhone[^()]+\(([0-9A-F-]{36})\)\s+\(Booted\)/; +const AVAILABLE_IPHONE_RE = /^\s+(iPhone[^(]+)\s+\(([0-9A-F-]{36})\)\s+\((?:Shutdown|Booted)\)/gm; const AMP_RE = /&/g; const LT_RE = //g; const DQUOTE_RE = /"/g; const SQUOTE_RE = /'/g; +const CREDENTIAL_KEY_RE = /(TOKEN|SECRET|PASSWORD|KEY|EMAIL|AUTH|CREDENTIAL)/i; +const WHITESPACE_RE = /\s+/; +const IPHONE_17_RE = /iPhone 17\b/; +const RESULT_BUNDLE_STAMP_RE = /[:.]/g; + +export type SwiftTestMode = + | 'unit' + | 'ios-smoke' + | 'ios-ui' + | 'all' + | 'mac-build' + | 'mac-smoke' + | 'mac-ui'; + +const SWIFT_TEST_MODES: readonly SwiftTestMode[] = [ + 'unit', + 'ios-smoke', + 'ios-ui', + 'all', + 'mac-build', + 'mac-smoke', + 'mac-ui', +]; +const KNOWN_SWIFT_TEST_MODES = new Set(SWIFT_TEST_MODES); +const IOS_SMOKE_FILTERS = [ + '-only-testing:PackRatUITests/AuthTests/testSuccessfulLogin', + '-only-testing:PackRatUITests/NavigationTests/testAllPrimaryTabsReachable', + '-only-testing:PackRatUITests/PackTests/testCreatePack', +]; +const MAC_SMOKE_FILTERS = [ + '-only-testing:PackRatMacUITests/MacSmokeTests', + '-only-testing:PackRatMacUITests/MacNavigationTests/testEverySidebarDestinationIsReachable', + '-only-testing:PackRatMacUITests/MacPackTripTests/testCreateOpenAndAddItemToPack', +]; -// ── Load .env.local ─────────────────────────────────────────────────────────── +type ParsedArgs = { + mode: SwiftTestMode; + passthrough: string[]; +}; -const envFile = resolve(REPO_ROOT, '.env.local'); -if (existsSync(envFile)) { - for (const line of readFileSync(envFile, 'utf8').split('\n')) { +type GitInfo = { + branch: string; + head: string; + upstream: string | null; + ahead: number | null; + behind: number | null; +}; + +type UITestEnvOptions = { + email: string; + password: string; + env?: NodeJS.ProcessEnv; +}; + +function isSwiftTestMode(value: string): value is SwiftTestMode { + return KNOWN_SWIFT_TEST_MODES.has(value); +} + +export function parseArgs(args: string[]): ParsedArgs { + const [first, ...rest] = args; + if (first && isSwiftTestMode(first)) { + return { mode: first, passthrough: rest }; + } + return { mode: 'ios-ui', passthrough: args }; +} + +export function loadDotEnv(path: string, env: NodeJS.ProcessEnv = process.env): void { + if (!existsSync(path)) return; + for (const line of readFileSync(path, 'utf8').split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eq = trimmed.indexOf('='); @@ -51,22 +125,74 @@ if (existsSync(envFile)) { .slice(eq + 1) .trim() .replace(QUOTE_RE, ''); - if (process.env[key] === undefined) process.env[key] = value; + if (env[key] === undefined) env[key] = value; + } +} + +export function redactSecrets(value: string, env: NodeJS.ProcessEnv = process.env): string { + let redacted = value; + for (const key of Object.keys(env)) { + if (!CREDENTIAL_KEY_RE.test(key)) continue; + const secret = env[key]; + if (!secret || secret.length < 3) continue; + redacted = redacted.split(secret).join(''); } + return redacted; } -const { E2E_EMAIL, E2E_PASSWORD } = process.env; -if (!E2E_EMAIL || !E2E_PASSWORD) { - console.error('❌ E2E_EMAIL and E2E_PASSWORD must be set in .env.local'); - process.exit(1); +function execGit(args: string[]): string | null { + try { + return execFileSync('git', args, { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; + } +} + +export function getGitInfo(): GitInfo { + const branch = execGit(['branch', '--show-current']) || '(detached)'; + const head = execGit(['rev-parse', '--short=12', 'HEAD']) || 'unknown'; + const upstream = execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); + let ahead: number | null = null; + let behind: number | null = null; + + if (upstream) { + const counts = execGit(['rev-list', '--left-right', '--count', `HEAD...${upstream}`]); + if (counts) { + const [left, right] = counts.split(WHITESPACE_RE).map((n) => Number.parseInt(n, 10)); + ahead = Number.isFinite(left) ? left : null; + behind = Number.isFinite(right) ? right : null; + } + } + + return { branch, head, upstream, ahead, behind }; } -if (!existsSync(SCHEME_PATH)) { - console.error(`❌ Scheme not found at ${SCHEME_PATH} — run 'bun swift' first`); - process.exit(1); +export function pickIOSDestination(simctlOutput: string): string { + const booted = simctlOutput.match(BOOTED_IPHONE_RE); + if (booted) return `platform=iOS Simulator,id=${booted[1]}`; + + const phones = [...simctlOutput.matchAll(AVAILABLE_IPHONE_RE)]; + const preferred = phones.find((match) => IPHONE_17_RE.test(match[1])) ?? phones[0]; + if (preferred) return `platform=iOS Simulator,id=${preferred[2]}`; + + return 'platform=iOS Simulator,name=iPhone 17'; } -// ── Inject credentials into scheme ─────────────────────────────────────────── +function currentIOSDestination(): string { + try { + return pickIOSDestination( + execFileSync('xcrun', ['simctl', 'list', 'devices', 'available'], { + encoding: 'utf8', + }), + ); + } catch { + return 'platform=iOS Simulator,name=iPhone 17'; + } +} function escapeXml(s: string): string { return s @@ -77,70 +203,253 @@ function escapeXml(s: string): string { .replace(SQUOTE_RE, '''); } -function injectScheme(email: string, password: string): void { - let content = readFileSync(SCHEME_PATH, 'utf8'); +export function injectSchemeEnv(schemePath: string, values: Record): void { + let content = readFileSync(schemePath, 'utf8'); - // Strip any prior EnvironmentVariables block (idempotent re-runs). content = content.replace(ENV_BLOCK_RE, ''); - - // Force TestAction to use its own env vars rather than inheriting from Run. content = content.replace(TEST_ACTION_INHERIT_RE, '$1shouldUseLaunchSchemeArgsEnv = "NO"'); - const block = [ - ' ', + const entries = Object.entries(values).flatMap(([key, value]) => [ ' ', - ' ', - ' ', ' ', + ]); + + const block = [ + ' ', + ...entries, ' ', '', ].join('\n'); - // Insert before . content = content.replace(' ', `${block} `); + writeFileSync(schemePath, content); +} - writeFileSync(SCHEME_PATH, content); +function schemePathForMode(mode: SwiftTestMode): string { + return mode === 'mac-build' || mode === 'mac-smoke' || mode === 'mac-ui' + ? MAC_SCHEME_PATH + : IOS_SCHEME_PATH; } -// ── Pick destination ───────────────────────────────────────────────────────── +function requireGeneratedProject(mode: SwiftTestMode): void { + const schemePath = schemePathForMode(mode); + if (!existsSync(schemePath)) { + console.error(`Scheme not found at ${schemePath}`); + console.error("Run 'bun swift' first to regenerate PackRat.xcodeproj."); + process.exit(1); + } +} -function pickDestination(): string { - try { - const out = execSync('xcrun simctl list devices booted', { encoding: 'utf8' }); - const match = out.match(SIMCTL_BOOTED_RE); - if (match) return `platform=iOS Simulator,id=${match[1]}`; - } catch {} - return 'platform=iOS Simulator,name=iPhone 16'; +function requireE2ECredentials(): { email: string; password: string } { + const email = process.env.E2E_EMAIL || process.env.E2E_TEST_EMAIL; + const password = process.env.E2E_PASSWORD || process.env.E2E_TEST_PASSWORD; + const missing = [ + ['E2E_EMAIL or E2E_TEST_EMAIL', email], + ['E2E_PASSWORD or E2E_TEST_PASSWORD', password], + ] + .filter(([, value]) => !value) + .map(([key]) => key); + + if (missing.length > 0) { + console.error(`Missing required UI test environment variable(s): ${missing.join(', ')}`); + console.error('Set them in .env.local or the process environment. Values are never printed.'); + process.exit(1); + } + + if (!email || !password) { + console.error('Missing required UI test credentials.'); + process.exit(1); + } + + return { email, password }; } -// ── Run xcodebuild ─────────────────────────────────────────────────────────── +function resultBundlePath(mode: SwiftTestMode): string { + mkdirSync(TEST_RESULTS_DIR, { recursive: true }); + const stamp = new Date().toISOString().replace(RESULT_BUNDLE_STAMP_RE, '-'); + const path = resolve(TEST_RESULTS_DIR, `${mode}-${stamp}.xcresult`); + if (existsSync(path)) rmSync(path, { recursive: true, force: true }); + return path; +} -injectScheme(E2E_EMAIL, E2E_PASSWORD); -console.log('✓ Injected E2E credentials into scheme'); +function printPreflight(mode: SwiftTestMode, destination: string | null): void { + const git = getGitInfo(); + console.log(`Swift test mode: ${mode}`); + console.log(`Git branch: ${git.branch}`); + console.log(`Git HEAD: ${git.head}`); + if (git.upstream) { + console.log( + `Git upstream: ${git.upstream} (ahead ${git.ahead ?? '?'}, behind ${git.behind ?? '?'})`, + ); + if ((git.behind ?? 0) > 0) { + console.log( + 'Warning: local branch is behind upstream. Fetch/rebase before editing shared files.', + ); + } + } else { + console.log('Warning: no upstream configured for this branch.'); + } + if (destination) console.log(`Destination: ${destination}`); +} -const dest = pickDestination(); -console.log(`→ Destination: ${dest}`); +function buildXcodeArgs(mode: SwiftTestMode, passthrough: string[]): string[] { + if (mode === 'mac-build') { + return [ + 'build', + '-project', + 'PackRat.xcodeproj', + '-scheme', + 'PackRat-macOS', + 'CODE_SIGNING_ALLOWED=NO', + ...passthrough, + ]; + } -const args = [ - 'test', - '-scheme', - 'PackRat-iOS', - '-destination', - dest, - '-only-testing:PackRatUITests', - ...process.argv.slice(2), -]; + if (mode === 'mac-smoke' || mode === 'mac-ui') { + return [ + 'test', + '-project', + 'PackRat.xcodeproj', + '-scheme', + 'PackRat-macOS', + '-resultBundlePath', + resultBundlePath(mode), + ...(mode === 'mac-smoke' ? MAC_SMOKE_FILTERS : ['-only-testing:PackRatMacUITests']), + 'CODE_SIGN_IDENTITY=-', + 'CODE_SIGN_STYLE=Manual', + 'DEVELOPMENT_TEAM=', + 'PROVISIONING_PROFILE_SPECIFIER=', + ...passthrough, + ]; + } + + const args = [ + 'test', + '-project', + 'PackRat.xcodeproj', + '-scheme', + 'PackRat-iOS', + '-destination', + currentIOSDestination(), + '-resultBundlePath', + resultBundlePath(mode), + ]; + + if (mode === 'unit') args.push('-only-testing:PackRatTests'); + if (mode === 'ios-smoke') args.push(...IOS_SMOKE_FILTERS); + if (mode === 'ios-ui') args.push('-only-testing:PackRatUITests'); + + return [...args, ...passthrough]; +} -const result = spawnSync('xcodebuild', args, { - cwd: SWIFT_DIR, - stdio: 'inherit', - env: process.env, -}); +function shouldInjectCredentials(mode: SwiftTestMode): boolean { + return ( + mode === 'ios-smoke' || + mode === 'ios-ui' || + mode === 'all' || + mode === 'mac-smoke' || + mode === 'mac-ui' + ); +} + +export function buildUITestEnv({ + email, + password, + env = process.env, +}: UITestEnvOptions): Record { + return { + E2E_EMAIL: email, + E2E_PASSWORD: password, + ...(env.E2E_API_BASE_URL ? { E2E_API_BASE_URL: env.E2E_API_BASE_URL } : {}), + E2E_SCREENSHOT_DIR: env.E2E_SCREENSHOT_DIR || resolve(TEST_RESULTS_DIR, 'screenshots'), + }; +} + +export function buildXcodeEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const allowed = [ + 'DEVELOPER_DIR', + 'HOME', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'LOGNAME', + 'PATH', + 'SDKROOT', + 'SHELL', + 'TMPDIR', + 'USER', + 'UsePerConfigurationBuildLocations', + ]; + + return Object.fromEntries( + allowed + .map((key) => [key, env[key]]) + .filter((entry): entry is [string, string] => Boolean(entry[1])), + ); +} + +async function runXcodebuild(args: string[]): Promise { + return await new Promise((resolve) => { + const child = spawn('xcodebuild', args, { + cwd: SWIFT_DIR, + stdio: ['ignore', 'pipe', 'pipe'], + env: buildXcodeEnv(), + }); + + child.stdout.on('data', (chunk: Buffer) => + process.stdout.write(redactSecrets(chunk.toString())), + ); + child.stderr.on('data', (chunk: Buffer) => + process.stderr.write(redactSecrets(chunk.toString())), + ); + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', (error) => { + console.error(redactSecrets(String(error))); + resolve(1); + }); + }); +} + +async function main(): Promise { + loadDotEnv(resolve(REPO_ROOT, '.env.local')); + const { mode, passthrough } = parseArgs(process.argv.slice(2)); + requireGeneratedProject(mode); + + if (shouldInjectCredentials(mode)) { + const { email, password } = requireE2ECredentials(); + const uiEnv = buildUITestEnv({ email, password }); + mkdirSync(uiEnv.E2E_SCREENSHOT_DIR, { recursive: true }); + injectSchemeEnv(schemePathForMode(mode), uiEnv); + const baseURLStatus = process.env.E2E_API_BASE_URL ? ', E2E_API_BASE_URL=' : ''; + console.log( + `Injected UI test environment into generated scheme: E2E_EMAIL=, E2E_PASSWORD=${baseURLStatus}, E2E_SCREENSHOT_DIR=${uiEnv.E2E_SCREENSHOT_DIR}`, + ); + } + + const args = buildXcodeArgs(mode, passthrough); + const destinationIndex = args.indexOf('-destination'); + const destination = destinationIndex === -1 ? null : args[destinationIndex + 1]; + printPreflight(mode, destination); + + const bundleIndex = args.indexOf('-resultBundlePath'); + if (bundleIndex !== -1) console.log(`Result bundle: ${args[bundleIndex + 1]}`); + + console.log(`Running: xcodebuild ${redactSecrets(args.join(' '))}`); + + process.exit(await runXcodebuild(args)); +} + +if (import.meta.main) { + void main(); +} -process.exit(result.status ?? 1); +export const paths = { + repoRoot: REPO_ROOT, + swiftDir: SWIFT_DIR, + iosSchemePath: IOS_SCHEME_PATH, + macSchemePath: MAC_SCHEME_PATH, + testResultsDir: TEST_RESULTS_DIR, +}; 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 f798cf9ba5..569d771456 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,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", "env": "bun run .github/scripts/env.ts", "expo": "cd apps/expo && bun start", "fix:deps": "bun manypkg fix", @@ -50,7 +55,9 @@ "test:e2e:ios": "bash .github/scripts/e2e.sh ios", "test:expo": "vitest run --config apps/expo/vitest.config.ts", "test:expo:rpc-types": "vitest run --config apps/expo/vitest.types.config.ts", - "test:mcp": "bun run --cwd packages/mcp test" + "test:mcp": "bun run --cwd packages/mcp test", + "test:swift:runner": "bun test apps/swift/scripts/run-e2e.test.ts", + "test:swift:unit": "bun run apps/swift/scripts/run-e2e.ts unit" }, "overrides": { "@sinclair/typebox": "^0.34.15", 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/drizzle/meta/0037_snapshot.json b/packages/api/drizzle/meta/0037_snapshot.json new file mode 100644 index 0000000000..70ab366aec --- /dev/null +++ b/packages/api/drizzle/meta/0037_snapshot.json @@ -0,0 +1,2070 @@ +{ + "id": "42af28ba-1745-412b-9c68-830a54b68048", + "prevId": "fa3d18d1-67a7-488a-aba5-5b18295e80f2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_providers": { + "name": "auth_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_providers_user_id_users_id_fk": { + "name": "auth_providers_user_id_users_id_fk", + "tableFrom": "auth_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_item_etl_jobs": { + "name": "catalog_item_etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "etl_job_id": { + "name": "etl_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk": { + "name": "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk": { + "name": "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "etl_jobs", + "columnsFrom": ["etl_job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_items": { + "name": "catalog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rating_value": { + "name": "rating_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "availability": { + "name": "availability", + "type": "availability", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "seller": { + "name": "seller", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "material": { + "name": "material", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "techs": { + "name": "techs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "links": { + "name": "links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "qas": { + "name": "qas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "faqs": { + "name": "faqs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "embedding_idx": { + "name": "embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "catalog_items_sku_unique": { + "name": "catalog_items_sku_unique", + "nullsNotDistinct": false, + "columns": ["sku"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_likes": { + "name": "comment_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_likes_comment_id_post_comments_id_fk": { + "name": "comment_likes_comment_id_post_comments_id_fk", + "tableFrom": "comment_likes", + "tableTo": "post_comments", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_likes_user_id_users_id_fk": { + "name": "comment_likes_user_id_users_id_fk", + "tableFrom": "comment_likes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comment_likes_comment_id_user_id_unique": { + "name": "comment_likes_comment_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["comment_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.etl_jobs": { + "name": "etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "etl_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_processed": { + "name": "total_processed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_valid": { + "name": "total_valid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_invalid": { + "name": "total_invalid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scraper_revision": { + "name": "scraper_revision", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "etl_jobs_scraper_revision_idx": { + "name": "etl_jobs_scraper_revision_idx", + "columns": [ + { + "expression": "scraper_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invalid_item_logs": { + "name": "invalid_item_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "row_index": { + "name": "row_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invalid_item_logs_job_id_etl_jobs_id_fk": { + "name": "invalid_item_logs_job_id_etl_jobs_id_fk", + "tableFrom": "invalid_item_logs", + "tableTo": "etl_jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.one_time_passwords": { + "name": "one_time_passwords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "one_time_passwords_user_id_users_id_fk": { + "name": "one_time_passwords_user_id_users_id_fk", + "tableFrom": "one_time_passwords", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_items": { + "name": "pack_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_ai_generated": { + "name": "is_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "template_item_id": { + "name": "template_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pack_items_embedding_idx": { + "name": "pack_items_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "pack_items_pack_id_packs_id_fk": { + "name": "pack_items_pack_id_packs_id_fk", + "tableFrom": "pack_items", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_user_id_users_id_fk": { + "name": "pack_items_user_id_users_id_fk", + "tableFrom": "pack_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_template_item_id_pack_template_items_id_fk": { + "name": "pack_items_template_item_id_pack_template_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "pack_template_items", + "columnsFrom": ["template_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_template_items": { + "name": "pack_template_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_template_id": { + "name": "pack_template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_template_items_pack_template_id_pack_templates_id_fk": { + "name": "pack_template_items_pack_template_id_pack_templates_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "pack_templates", + "columnsFrom": ["pack_template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_template_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_template_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_template_items_user_id_users_id_fk": { + "name": "pack_template_items_user_id_users_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_templates": { + "name": "pack_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_app_template": { + "name": "is_app_template", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "content_source": { + "name": "content_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_templates_user_id_users_id_fk": { + "name": "pack_templates_user_id_users_id_fk", + "tableFrom": "pack_templates", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weight_history": { + "name": "weight_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "weight_history_user_id_users_id_fk": { + "name": "weight_history_user_id_users_id_fk", + "tableFrom": "weight_history", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "weight_history_pack_id_packs_id_fk": { + "name": "weight_history_pack_id_packs_id_fk", + "tableFrom": "weight_history", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packs": { + "name": "packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_ai_generated": { + "name": "is_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "packs_user_id_users_id_fk": { + "name": "packs_user_id_users_id_fk", + "tableFrom": "packs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "packs_template_id_pack_templates_id_fk": { + "name": "packs_template_id_pack_templates_id_fk", + "tableFrom": "packs", + "tableTo": "pack_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_comments": { + "name": "post_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_comment_id": { + "name": "parent_comment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_comments_post_id_posts_id_fk": { + "name": "post_comments_post_id_posts_id_fk", + "tableFrom": "post_comments", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_user_id_users_id_fk": { + "name": "post_comments_user_id_users_id_fk", + "tableFrom": "post_comments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comments_parent_comment_id_post_comments_id_fk": { + "name": "post_comments_parent_comment_id_post_comments_id_fk", + "tableFrom": "post_comments", + "tableTo": "post_comments", + "columnsFrom": ["parent_comment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_likes": { + "name": "post_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_likes_post_id_posts_id_fk": { + "name": "post_likes_post_id_posts_id_fk", + "tableFrom": "post_likes", + "tableTo": "posts", + "columnsFrom": ["post_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_likes_user_id_users_id_fk": { + "name": "post_likes_user_id_users_id_fk", + "tableFrom": "post_likes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_likes_post_id_user_id_unique": { + "name": "post_likes_post_id_user_id_unique", + "nullsNotDistinct": false, + "columns": ["post_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "posts_user_id_users_id_fk": { + "name": "posts_user_id_users_id_fk", + "tableFrom": "posts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "replaced_by_token": { + "name": "replaced_by_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_unique": { + "name": "refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reported_content": { + "name": "reported_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_response": { + "name": "ai_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_comment": { + "name": "user_comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed": { + "name": "reviewed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reported_content_user_id_users_id_fk": { + "name": "reported_content_user_id_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reported_content_reviewed_by_users_id_fk": { + "name": "reported_content_reviewed_by_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["reviewed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trail_condition_reports": { + "name": "trail_condition_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trail_name": { + "name": "trail_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trail_region": { + "name": "trail_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "overall_condition": { + "name": "overall_condition", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hazards": { + "name": "hazards", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "water_crossings": { + "name": "water_crossings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "water_crossing_difficulty": { + "name": "water_crossing_difficulty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photos": { + "name": "photos", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "trip_id": { + "name": "trip_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trail_condition_reports_user_id_idx": { + "name": "trail_condition_reports_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_active_created_idx": { + "name": "trail_condition_reports_active_created_idx", + "columns": [ + { + "expression": "deleted", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_trail_name_idx": { + "name": "trail_condition_reports_trail_name_idx", + "columns": [ + { + "expression": "trail_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trail_condition_reports_trip_id_idx": { + "name": "trail_condition_reports_trip_id_idx", + "columns": [ + { + "expression": "trip_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trail_condition_reports\".\"trip_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trail_condition_reports_user_id_users_id_fk": { + "name": "trail_condition_reports_user_id_users_id_fk", + "tableFrom": "trail_condition_reports", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trail_condition_reports_trip_id_trips_id_fk": { + "name": "trail_condition_reports_trip_id_trips_id_fk", + "tableFrom": "trail_condition_reports", + "tableTo": "trips", + "columnsFrom": ["trip_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trips": { + "name": "trips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "trips_user_id_users_id_fk": { + "name": "trips_user_id_users_id_fk", + "tableFrom": "trips", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trips_pack_id_packs_id_fk": { + "name": "trips_pack_id_packs_id_fk", + "tableFrom": "trips", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'USER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index 70f2f73413..ccaf761e93 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -267,6 +267,13 @@ "when": 1775883868581, "tag": "0036_typical_zuras", "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1778135555239, + "tag": "0037_big_archangel", + "breakpoints": true } ] } diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 65b064d748..ce7c05eec6 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -14,6 +14,7 @@ import { AnalyzeImageRequestSchema } from '@packrat/api/schemas/imageDetection'; import { CreatePackItemRequestSchema, CreatePackRequestSchema, + DEFAULT_PACK_CATEGORY, GapAnalysisRequestSchema, UpdatePackItemRequestSchema, UpdatePackRequestSchema, @@ -108,34 +109,40 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) '/', async ({ body, user }) => { const db = createDb(); - const data = body; + try { + const data = body; - const packId = data.id as string; - if (!packId) return status(400, { error: 'Pack ID is required' }); + const packId = data.id as string; + if (!packId) return status(400, { error: 'Pack ID is required' }); - // Zod validates all fields at runtime; cast through the Standard Schema - // inference gap so drizzle's insert accepts the values. - const [newPack] = await db - .insert(packs) - .values({ - id: packId, - userId: user.userId, - name: data.name, - description: data.description, - // category column is NOT NULL — default to 'custom' when callers omit it. - category: data.category ?? 'custom', - isPublic: data.isPublic, - 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(); + const category = data.category || DEFAULT_PACK_CATEGORY; - if (!newPack) return status(400, { error: 'Failed to create pack' }); + // Zod validates all fields at runtime; cast through the Standard Schema + // inference gap so drizzle's insert accepts the values. + const [newPack] = await db + .insert(packs) + .values({ + id: packId, + userId: user.userId, + name: data.name, + description: data.description ?? null, + 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(); - const packWithItems: PackWithItems = { ...newPack, items: [] }; - return computePacksWeights([packWithItems])[0]; + if (!newPack) return status(400, { error: 'Failed to create pack' }); + + const packWithItems: PackWithItems = { ...newPack, items: [] }; + return computePacksWeights([packWithItems])[0]; + } catch (error) { + console.error('Error creating pack:', error); + return status(500, { error: 'Failed to create pack' }); + } }, { body: CreatePackBodySchema, diff --git a/packages/api/src/schemas/packs.ts b/packages/api/src/schemas/packs.ts index 46c624c9ff..0fc8a5e8ee 100644 --- a/packages/api/src/schemas/packs.ts +++ b/packages/api/src/schemas/packs.ts @@ -1,6 +1,8 @@ import { PACK_CATEGORIES, WEIGHT_UNITS } from '@packrat/api/types'; import { z } from 'zod'; +export const DEFAULT_PACK_CATEGORY = 'custom' satisfies (typeof PACK_CATEGORIES)[number]; + export const PackItemSchema = z.object({ id: z.string(), name: z.string(), @@ -49,11 +51,16 @@ export const PackWithWeightsSchema = PackSchema.extend({ export const CreatePackRequestSchema = z.object({ name: z.string().min(1).max(255), - description: z.string().optional(), - category: z.string().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({ diff --git a/packages/api/test/packs.test.ts b/packages/api/test/packs.test.ts index 65d61309df..b97d8a1428 100644 --- a/packages/api/test/packs.test.ts +++ b/packages/api/test/packs.test.ts @@ -174,6 +174,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);