diff --git a/.github/scripts/bump.ts b/.github/scripts/bump.ts index b9f48c15d7..fab70deb37 100755 --- a/.github/scripts/bump.ts +++ b/.github/scripts/bump.ts @@ -69,6 +69,28 @@ try { console.error(`❌ Failed to update app.config.ts:`, error); } +// Update the Swift project's MARKETING_VERSION for every target (iOS, macOS, +// watchOS). The Swift app ships to the same App Store record as the Expo build, +// so its marketing version must track the monorepo version — otherwise the next +// TestFlight upload collides with an already-released version. +const swiftProjectPath = join(process.cwd(), 'apps/swift/project.yml'); +const RE_MARKETING_VERSION = /MARKETING_VERSION:\s*"[^"]*"/g; +try { + const content = readFileSync(swiftProjectPath, 'utf-8'); + const matches = content.match(RE_MARKETING_VERSION)?.length ?? 0; + if (matches === 0) { + console.error( + `❌ No MARKETING_VERSION entries found in ${swiftProjectPath}; Swift version NOT bumped. Update it by hand before uploading to TestFlight.`, + ); + } else { + const updated = content.replace(RE_MARKETING_VERSION, `MARKETING_VERSION: "${newVersion}"`); + writeFileSync(swiftProjectPath, updated); + console.log(`✅ Updated ${swiftProjectPath} (${matches} target(s))`); + } +} catch (error) { + console.error(`❌ Failed to update project.yml:`, error); +} + // Commit and tag as last step try { await $`git add .`; diff --git a/.github/workflows/swift-ci.yml b/.github/workflows/swift-ci.yml index 6bc0d19800..57d475ce1a 100644 --- a/.github/workflows/swift-ci.yml +++ b/.github/workflows/swift-ci.yml @@ -27,6 +27,7 @@ jobs: build-and-smoke: name: ${{ matrix.scheme }} (smoke) runs-on: macos-15 + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -70,24 +71,47 @@ jobs: "$(xcrun simctl list runtimes -j | jq -r '.runtimes[] | select(.identifier | contains("iOS-")) | .identifier' | sort -V | tail -1)") xcrun simctl boot "$DEVICE_ID" xcrun simctl bootstatus "$DEVICE_ID" + echo "IOS_SIMULATOR_ID=$DEVICE_ID" >> "$GITHUB_ENV" - name: Run ${{ matrix.plan }} test plan + timeout-minutes: 35 env: E2E_EMAIL: ${{ secrets.E2E_EMAIL }} E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} run: | + set -o pipefail cd apps/swift # Use signing-bypass on macOS so CI can build/test without a Mac Development cert - # provisioned for the runner. iOS Simulator never needs signing. - if [ "${{ matrix.scheme }}" = "PackRat-macOS" ]; then - CODE_SIGN_ARGS=(CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" CODE_SIGNING_ALLOWED=NO) + # provisioned for the runner. iOS Simulator never needs signing, but + # it does need the same PACKRAT_E2E_* build settings as the local + # wrapper so smoke auth does not depend on repository secrets. + if [ "${{ matrix.scheme }}" = "PackRat-iOS" ]; then + E2E_EMAIL="${E2E_EMAIL:-e2e@packrattest.local}" + E2E_PASSWORD="${E2E_PASSWORD:-E2eTestPass123!}" + CODE_SIGN_ARGS=( + "PACKRAT_E2E_EMAIL=${E2E_EMAIL}" + "PACKRAT_E2E_PASSWORD=${E2E_PASSWORD}" + "PACKRAT_E2E_ALLOW_LOGIN_SEED=1" + "PACKRAT_ENV=local" + ) else - CODE_SIGN_ARGS=() + CODE_SIGN_ARGS=( + CODE_SIGN_STYLE=Manual + DEVELOPMENT_TEAM= + CODE_SIGN_IDENTITY=- + CODE_SIGNING_ALLOWED=YES + CODE_SIGNING_REQUIRED=NO + ) fi + DESTINATION="${{ matrix.destination }}" + if [ "${{ matrix.scheme }}" = "PackRat-iOS" ]; then + DESTINATION="platform=iOS Simulator,id=${IOS_SIMULATOR_ID}" + fi + echo "Testing ${{ matrix.scheme }} on ${DESTINATION}" xcodebuild test \ -scheme "${{ matrix.scheme }}" \ - -destination "${{ matrix.destination }}" \ + -destination "${DESTINATION}" \ -testPlan "${{ matrix.plan }}" \ -resultBundlePath "/tmp/${{ matrix.scheme }}-smoke.xcresult" \ -quiet \ @@ -100,7 +124,12 @@ jobs: if [ -d "/tmp/${{ matrix.scheme }}-smoke.xcresult" ]; then xcrun xcresulttool get test-results summary \ --path "/tmp/${{ matrix.scheme }}-smoke.xcresult" --compact \ + | tee "/tmp/${{ matrix.scheme }}-smoke-summary.json" \ | jq '{ result, totalTestCount, passedTests, failedTests, skippedTests, expectedFailures }' || true + if [ -f "/tmp/${{ matrix.scheme }}-smoke-summary.json" ]; then + jq -e '.result == "Passed" and (.failedTests // 0) == 0' \ + "/tmp/${{ matrix.scheme }}-smoke-summary.json" >/dev/null + fi fi - name: Upload xcresult on failure diff --git a/.github/workflows/swift-e2e.yml b/.github/workflows/swift-e2e.yml index f47e7e5e2b..75c7435602 100644 --- a/.github/workflows/swift-e2e.yml +++ b/.github/workflows/swift-e2e.yml @@ -35,6 +35,52 @@ on: required: false type: boolean default: false + ios_test_plan: + description: "iOS test depth for manual runs" + required: false + type: choice + default: sanity + options: + - sanity + - smoke + - full + api_environment: + description: "API target for manual runs" + required: false + type: choice + default: configured-secret + options: + - configured-secret + - local + - dev + - production + feature_flag_profile: + description: "Temporary Swift feature flag profile" + required: false + type: choice + default: default + options: + - default + - all-on + - all-off + run_testflight_preflight: + description: "Verify replacement TestFlight metadata without archiving" + required: false + type: boolean + default: false + run_testflight_archive_verification: + description: "Archive/export the replacement app on a Mac runner and verify the built binary metadata without uploading" + required: false + type: boolean + default: false + replacement_build_number: + description: "Swift replacement build number intended for upload" + required: false + type: string + current_app_store_build_number: + description: "Latest existing PackRat App Store/TestFlight build number" + required: false + type: string concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -52,6 +98,87 @@ env: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} jobs: + testflight-replacement-preflight: + name: TestFlight replacement preflight + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name == 'workflow_dispatch' && inputs.run_testflight_preflight + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install --frozen-lockfile + + - name: Verify replacement TestFlight metadata + env: + BUILD_NUMBER: ${{ inputs.replacement_build_number }} + APP_STORE_CURRENT_BUILD_NUMBER: ${{ inputs.current_app_store_build_number }} + run: | + missing=() + [ -z "${BUILD_NUMBER:-}" ] && missing+=("replacement_build_number") + [ -z "${APP_STORE_CURRENT_BUILD_NUMBER:-}" ] && missing+=("current_app_store_build_number") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Required TestFlight preflight inputs missing: ${missing[*]}" + exit 1 + fi + bun swift:testflight:preflight --replacement --production + + testflight-replacement-archive: + name: TestFlight replacement archive verification + runs-on: [self-hosted, macOS, packrat-e2e] + timeout-minutes: 60 + if: github.event_name == 'workflow_dispatch' && inputs.run_testflight_archive_verification + + 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 + + - name: Install dependencies + env: + PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} + run: bun install --frozen-lockfile + + - name: Install xcodegen + run: brew install xcodegen + + - name: Generate Swift Xcode project + run: bun run swift + + - name: Verify replacement archive/export metadata + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + BUILD_NUMBER: ${{ inputs.replacement_build_number }} + APP_STORE_CURRENT_BUILD_NUMBER: ${{ inputs.current_app_store_build_number }} + run: | + missing=() + [ -z "${APPLE_TEAM_ID:-}" ] && missing+=("APPLE_TEAM_ID") + [ -z "${BUILD_NUMBER:-}" ] && missing+=("replacement_build_number") + [ -z "${APP_STORE_CURRENT_BUILD_NUMBER:-}" ] && missing+=("current_app_store_build_number") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Required TestFlight archive verification inputs missing: ${missing[*]}" + exit 1 + fi + bun apps/swift/scripts/upload-testflight.ts --replacement --production --verify-archive-only + macos-ui: name: macOS Swift UI E2E runs-on: [self-hosted, macOS, packrat-e2e] @@ -74,18 +201,55 @@ jobs: uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Install dependencies run: bun install --frozen-lockfile + - name: Install xcodegen + run: brew install xcodegen + + - name: Resolve Swift E2E target + run: | + target="${{ inputs.api_environment || 'configured-secret' }}" + if [ "${{ github.event_name }}" = "schedule" ]; then + target="dev" + fi + case "$target" in + configured-secret) + echo "PACKRAT_ENV=dev" >> "$GITHUB_ENV" + ;; + local) + echo "PACKRAT_ENV=local" >> "$GITHUB_ENV" + echo "E2E_API_BASE_URL=" >> "$GITHUB_ENV" + ;; + dev) + echo "PACKRAT_ENV=dev" >> "$GITHUB_ENV" + echo "E2E_API_BASE_URL=https://packrat-api-dev.orange-frost-d665.workers.dev" >> "$GITHUB_ENV" + ;; + production) + echo "PACKRAT_ENV=production" >> "$GITHUB_ENV" + echo "E2E_API_BASE_URL=https://packrat-api.orange-frost-d665.workers.dev" >> "$GITHUB_ENV" + ;; + esac + echo "PACKRAT_SWIFT_FEATURE_FLAG_PROFILE=${{ inputs.feature_flag_profile || 'default' }}" >> "$GITHUB_ENV" + - name: Verify Swift E2E secrets run: | + target="${{ inputs.api_environment || 'configured-secret' }}" + if [ "${{ github.event_name }}" = "schedule" ]; then + target="dev" + fi 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 [ "$target" != "local" ]; then + [ -z "${E2E_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") + [ -z "${E2E_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") + fi + if [ "$target" = "configured-secret" ] && [ -z "${E2E_API_BASE_URL:-}" ]; then + missing+=("SWIFT_E2E_API_BASE_URL") + fi + if [ "$target" != "local" ] && [ "$target" != "production" ] && [ -z "${NEON_DATABASE_URL:-}" ]; then + missing+=("NEON_DEV_DATABASE_URL") + fi if [ ${#missing[@]} -gt 0 ]; then echo "::error::Required Swift E2E secrets missing: ${missing[*]}" exit 1 @@ -101,12 +265,17 @@ jobs: run: bun run swift - name: Seed E2E test user + if: ${{ (inputs.api_environment || 'configured-secret') != 'local' && (inputs.api_environment || 'configured-secret') != 'production' }} 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: Verify deployed Swift auth + if: ${{ (inputs.api_environment || 'configured-secret') != 'local' }} + run: bun run apps/swift/scripts/verify-deployed-auth.ts + - name: Run macOS Swift UI E2E run: | if [ "${{ github.event_name }}" = "pull_request" ]; then @@ -159,6 +328,7 @@ jobs: timeout-minutes: 60 if: > github.event_name == 'schedule' || + github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.run_ios_ui) steps: @@ -174,18 +344,57 @@ jobs: uses: oven-sh/setup-bun@v2 with: bun-version: latest - cache: true - name: Install dependencies run: bun install --frozen-lockfile + - name: Install xcodegen + run: brew install xcodegen + + - name: Resolve Swift E2E target + run: | + target="${{ inputs.api_environment || 'configured-secret' }}" + if [ "${{ github.event_name }}" = "pull_request" ] || + [ "${{ github.event_name }}" = "schedule" ]; then + target="dev" + fi + case "$target" in + configured-secret) + echo "PACKRAT_ENV=dev" >> "$GITHUB_ENV" + ;; + local) + echo "PACKRAT_ENV=local" >> "$GITHUB_ENV" + echo "E2E_API_BASE_URL=" >> "$GITHUB_ENV" + ;; + dev) + echo "PACKRAT_ENV=dev" >> "$GITHUB_ENV" + echo "E2E_API_BASE_URL=https://packrat-api-dev.orange-frost-d665.workers.dev" >> "$GITHUB_ENV" + ;; + production) + echo "PACKRAT_ENV=production" >> "$GITHUB_ENV" + echo "E2E_API_BASE_URL=https://packrat-api.orange-frost-d665.workers.dev" >> "$GITHUB_ENV" + ;; + esac + echo "PACKRAT_SWIFT_FEATURE_FLAG_PROFILE=${{ inputs.feature_flag_profile || 'default' }}" >> "$GITHUB_ENV" + - name: Verify Swift E2E secrets run: | + target="${{ inputs.api_environment || 'configured-secret' }}" + if [ "${{ github.event_name }}" = "pull_request" ] || + [ "${{ github.event_name }}" = "schedule" ]; then + target="dev" + fi 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 [ "$target" != "local" ]; then + [ -z "${E2E_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") + [ -z "${E2E_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") + fi + if [ "$target" = "configured-secret" ] && [ -z "${E2E_API_BASE_URL:-}" ]; then + missing+=("SWIFT_E2E_API_BASE_URL") + fi + if [ "$target" != "local" ] && [ "$target" != "production" ] && [ -z "${NEON_DATABASE_URL:-}" ]; then + missing+=("NEON_DEV_DATABASE_URL") + fi if [ ${#missing[@]} -gt 0 ]; then echo "::error::Required Swift E2E secrets missing: ${missing[*]}" exit 1 @@ -197,14 +406,38 @@ jobs: run: bun run swift - name: Seed E2E test user + if: ${{ (github.event_name == 'pull_request' || (inputs.api_environment || 'configured-secret') != 'local') && (inputs.api_environment || 'configured-secret') != 'production' }} 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: Verify deployed Swift auth + if: ${{ github.event_name == 'pull_request' || (inputs.api_environment || 'configured-secret') != 'local' }} + run: bun run apps/swift/scripts/verify-deployed-auth.ts + + - name: Boot iOS simulator + run: | + DEVICE_ID=$(xcrun simctl create Swift-E2E "iPhone 17" \ + "$(xcrun simctl list runtimes -j | jq -r '.runtimes[] | select(.identifier | contains("iOS-")) | .identifier' | sort -V | tail -1)") + xcrun simctl boot "$DEVICE_ID" + xcrun simctl bootstatus "$DEVICE_ID" + echo "IOS_SIMULATOR_ID=$DEVICE_ID" >> "$GITHUB_ENV" + - name: Run iOS Swift UI E2E - run: bun run e2e:swift:ios + run: | + plan="${{ inputs.ios_test_plan || 'full' }}" + if [ "${{ github.event_name }}" = "pull_request" ]; then + plan="sanity" + fi + if [ "$plan" = "full" ]; then + bun run e2e:swift:ios + elif [ "$plan" = "smoke" ]; then + bun run e2e:swift:ios-smoke + else + bun run e2e:swift:ios-sanity + fi - name: Summarize iOS xcresult if: always() diff --git a/.github/workflows/swift-visual.yml b/.github/workflows/swift-visual.yml index 59a6e90e9b..c493fff998 100644 --- a/.github/workflows/swift-visual.yml +++ b/.github/workflows/swift-visual.yml @@ -8,13 +8,33 @@ on: platform: description: "Which visual suite to run" required: true - default: both + default: all type: choice options: + - all - both - ios - ipad - macos + - watch + api_environment: + description: "API target for screenshot capture" + required: false + type: choice + default: production + options: + - production + - local + - dev + feature_flag_profile: + description: "Temporary Swift feature flag profile" + required: false + type: choice + default: default + options: + - default + - all-on + - all-off concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -74,11 +94,50 @@ jobs: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} run: bun install --frozen-lockfile + - name: Resolve Swift visual target + run: | + target="${{ inputs.api_environment || 'production' }}" + case "$target" in + local) + echo "PACKRAT_ENV=local" >> "$GITHUB_ENV" + echo "E2E_API_BASE_URL=" >> "$GITHUB_ENV" + ;; + dev) + echo "PACKRAT_ENV=dev" >> "$GITHUB_ENV" + echo "E2E_API_BASE_URL=https://packrat-api-dev.orange-frost-d665.workers.dev" >> "$GITHUB_ENV" + ;; + production) + echo "PACKRAT_ENV=production" >> "$GITHUB_ENV" + echo "E2E_API_BASE_URL=https://packrat-api.orange-frost-d665.workers.dev" >> "$GITHUB_ENV" + ;; + esac + echo "PACKRAT_SWIFT_FEATURE_FLAG_PROFILE=${{ inputs.feature_flag_profile || 'default' }}" >> "$GITHUB_ENV" + + - name: Verify Swift visual deployed prerequisites + if: ${{ (inputs.api_environment || 'production') == 'dev' }} + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + run: | + missing=() + [ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL") + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::Required Swift visual dev secrets missing: ${missing[*]}" + exit 1 + fi + + - name: Seed dev E2E test user + if: ${{ (inputs.api_environment || 'production') == 'dev' }} + run: bun run --filter @packrat/api db:seed:e2e-user + env: + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} + - name: Generate Xcode project run: bun swift - name: Boot iOS simulator - if: ${{ (github.event.inputs.platform || 'both') != 'macos' }} + if: ${{ (github.event.inputs.platform || 'all') == 'ios' || (github.event.inputs.platform || 'all') == 'all' || (github.event.inputs.platform || 'all') == 'both' }} run: | DEVICE_ID=$(xcrun simctl create Swift-Visual "iPhone 17" \ "$(xcrun simctl list runtimes -j | jq -r '.runtimes[] | select(.identifier | contains("iOS-")) | .identifier' | sort -V | tail -1)") @@ -86,7 +145,7 @@ jobs: xcrun simctl bootstatus "$DEVICE_ID" - name: Boot iPad simulator - if: ${{ (github.event.inputs.platform || 'both') == 'ipad' || (github.event.inputs.platform || 'both') == 'both' }} + if: ${{ (github.event.inputs.platform || 'all') == 'ipad' || (github.event.inputs.platform || 'all') == 'all' || (github.event.inputs.platform || 'all') == 'both' }} run: | DEVICE_ID=$(xcrun simctl list devices available -j \ | jq -r '[.devices[][] | select(.isAvailable == true and (.name | contains("iPad")))] | .[0].udid') @@ -94,7 +153,7 @@ jobs: xcrun simctl bootstatus "$DEVICE_ID" - name: Check macOS Automation Mode - if: ${{ (github.event.inputs.platform || 'both') == 'macos' || (github.event.inputs.platform || 'both') == 'both' }} + if: ${{ (github.event.inputs.platform || 'all') == 'macos' || (github.event.inputs.platform || 'all') == 'all' || (github.event.inputs.platform || 'all') == 'both' }} run: automationmodetool help - name: Capture Swift visual screenshots @@ -105,7 +164,7 @@ jobs: E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} PACKRAT_VISUAL_XCODEBUILD_TIMEOUT_MS: "3600000" run: | - PLATFORM="${{ github.event.inputs.platform || 'both' }}" + PLATFORM="${{ github.event.inputs.platform || 'all' }}" bun swift:screenshots --platform "$PLATFORM" --out "$SCREENSHOT_ARTIFACT_DIR" - name: Upload contact sheets diff --git a/apps/admin/package.json b/apps/admin/package.json index e62a341082..7527f2d5c8 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "packrat-admin-app", - "version": "2.1.0", + "version": "2.2.0", "private": true, "scripts": { "build": "next build", diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 5da53e6736..fbd78eccf0 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -145,7 +145,7 @@ export default (): ExpoConfig => { name: getAppName(), slug: 'packrat', - version: '2.1.0', + version: '2.2.0', scheme: 'packrat', web: { bundler: 'metro', diff --git a/apps/expo/features/trips/utils/getTripDetailOptions.tsx b/apps/expo/features/trips/utils/getTripDetailOptions.tsx index 892b0d3d79..85c381ae4f 100644 --- a/apps/expo/features/trips/utils/getTripDetailOptions.tsx +++ b/apps/expo/features/trips/utils/getTripDetailOptions.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { t } from 'expo-app/lib/i18n'; import { testIds } from 'expo-app/lib/testIds'; import { useRouter } from 'expo-router'; +import { useState } from 'react'; import { Platform, Share, View } from 'react-native'; import { useDeleteTrip } from '../hooks'; @@ -19,6 +20,7 @@ export function getTripDetailOptions(id: string) { const router = useRouter(); const deleteTrip = useDeleteTrip(); const trip = useTripDetailsFromStore(id); + const [isDeleting, setIsDeleting] = useState(false); const handleShare = async () => { if (!trip) return; @@ -36,15 +38,25 @@ export function getTripDetailOptions(id: string) { } }; - const deleteAndNavigate = () => { - deleteTrip(id); - if (router.canGoBack()) router.back(); + const deleteAndNavigate = async () => { + if (isDeleting) return; + setIsDeleting(true); + try { + await deleteTrip(id); + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/trips'); + } + } finally { + setIsDeleting(false); + } }; const confirmDelete = () => { if (Platform.OS === 'web') { if (globalThis.confirm(t('trips.deleteTripConfirmation'))) { - deleteAndNavigate(); + void deleteAndNavigate(); } return; } @@ -57,7 +69,9 @@ export function getTripDetailOptions(id: string) { { text: t('common.delete'), style: 'destructive', - onPress: deleteAndNavigate, + onPress: () => { + void deleteAndNavigate(); + }, }, ], }); @@ -77,6 +91,7 @@ export function getTripDetailOptions(id: string) { testID={testIds.trips.deleteBtn} variant="plain" size="icon" + disabled={isDeleting} onPress={confirmDelete} > diff --git a/apps/expo/package.json b/apps/expo/package.json index dbb3099355..caa95eeaff 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -1,6 +1,6 @@ { "name": "packrat-expo-app", - "version": "2.1.0", + "version": "2.2.0", "private": true, "main": "expo-router/entry", "scripts": { diff --git a/apps/guides/package.json b/apps/guides/package.json index 73cb90eeac..7926f8a15e 100644 --- a/apps/guides/package.json +++ b/apps/guides/package.json @@ -1,6 +1,6 @@ { "name": "packrat-guides-app", - "version": "2.1.0", + "version": "2.2.0", "private": true, "scripts": { "build": "bun run build-content && bun run generate-og-images && next build", diff --git a/apps/landing/package.json b/apps/landing/package.json index 7ca7d3d6a9..67a3437271 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -1,6 +1,6 @@ { "name": "packrat-landing-app", - "version": "2.1.0", + "version": "2.2.0", "private": true, "scripts": { "build": "bun run generate-og-images && next build", diff --git a/apps/swift/README.md b/apps/swift/README.md index d6ba1bd690..c48c274b87 100644 --- a/apps/swift/README.md +++ b/apps/swift/README.md @@ -1,4 +1,4 @@ -# PackRat Swift Testing +# PackRat Native Testing The generated Xcode project is not committed. Regenerate it after changing `project.yml`: @@ -19,6 +19,7 @@ mkdir -p /Volumes/CrucialX10/tmp/andrewbierman ```sh bun run test:swift:runner bun run test:swift:unit +bun run e2e:swift:ios-sanity bun run e2e:swift:ios-smoke bun run e2e:swift:ios bun run e2e:swift:mac @@ -29,10 +30,15 @@ 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/`. +Sanity mode is the fastest TestFlight-style loading check: + +- `e2e:swift:ios-sanity`: iOS launch, guest entry, real login, and primary tabs. + 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. +- `e2e:swift:ios-smoke`: iOS auth, guest persistence, feature gating, navigation, + search/filter/explore entry points, and weather entry. Full modes are the platform confidence gates: @@ -71,6 +77,68 @@ Swift E2E CI is defined in `.github/workflows/swift-e2e.yml`. See `docs/ci/swift-e2e-runner.md` for self-hosted Mac runner setup. +## TestFlight Identity + +The Swift iOS app defaults to the replacement identity for the existing Expo/App +Store listing: + +- iOS: `com.andrewbierman.packrat`, display name `PackRat` +- watchOS companion: `com.andrewbierman.packrat.watchkitapp` + +That is the only identity that validates a seamless update for existing testers. +The upload tooling still supports an explicit side-by-side lane for unusual +parallel QA builds, but it must be requested intentionally with `--side-by-side`; +iOS treats that as a separate app with separate install, keychain, and app +container state. + +Upload commands require an explicit lane so we do not accidentally target the +wrong App Store Connect record: + +```sh +APP_STORE_CURRENT_BUILD_NUMBER=2026071801 BUILD_NUMBER=2026071802 \ + bun swift:testflight:preflight --replacement --production +bun apps/swift/scripts/upload-testflight.ts --replacement --dry-run +bun apps/swift/scripts/upload-testflight.ts --replacement --verify-archive-only +bun apps/swift/scripts/upload-testflight.ts --replacement +bun apps/swift/scripts/upload-testflight.ts --side-by-side --staging +``` + +`--staging` uses the Staging build config (`PACKRAT_ENV=dev`). Without it, the +script archives Release (`PACKRAT_ENV=production`). + +Use `--dry-run` before a real upload to verify the lane, bundle id, display +name, build configuration, API environment, and Xcode archive overrides without +requiring Apple credentials or running Xcode. + +Use `--verify-archive-only` on a self-hosted Mac signing runner before a real +replacement upload when you want TestFlight-level confidence without shipping a +build. It archives, exports, and inspects the built app/IPA metadata for the +replacement bundle ids, display name, build number, production `PACKRAT_ENV`, +and embedded watch companion linkage, then exits before upload. The hosted +GitHub macOS runners do not have a signed-in Apple account or provisioning +profiles, so the manual archive verification job runs on the same +`self-hosted`, `macOS`, `packrat-e2e` runner used for Mac app UI automation. + +Use `swift:testflight:preflight` before the replacement upload when validating a +seamless update. It fails unless the resolved archive is the existing Expo +listing (`com.andrewbierman.packrat`, `PackRat`), Release/production, and has a +strictly greater build number than `APP_STORE_CURRENT_BUILD_NUMBER`. Real +`--replacement` uploads enforce the same check before reading Apple credentials +or archiving. + +The same check is available from the manual **Swift E2E Tests** GitHub workflow: +enable `run_testflight_preflight`, then provide `replacement_build_number` and +`current_app_store_build_number`. That verifies the replacement metadata without +archiving or uploading. + +The manual **Swift E2E Tests** workflow can also run +`run_testflight_archive_verification` with the same build-number inputs. That +uses a macOS runner to archive/export and verify the actual binary metadata +without uploading to TestFlight. The **Swift Visual Screenshots** workflow +defaults to production real-auth screenshots so the recurring contact sheets do +not accidentally prove only local seeded behavior; choose `local` explicitly for +fixture-backed visual review. + ## Data Isolation Swift E2E tests use unique names for records they create. That keeps repeated diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-20.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-20.png new file mode 100644 index 0000000000..b5d3ce6767 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-20.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-20@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-20@2x.png new file mode 100644 index 0000000000..fe258e8108 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-20@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-29.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-29.png new file mode 100644 index 0000000000..3cb25420f2 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-29.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-29@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-29@2x.png new file mode 100644 index 0000000000..6a4c2d88d6 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-29@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-40.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-40.png new file mode 100644 index 0000000000..fe258e8108 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-40.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-40@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-40@2x.png new file mode 100644 index 0000000000..684e64feee Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-40@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-76.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-76.png new file mode 100644 index 0000000000..365c59baaa Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-76.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-76@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-76@2x.png new file mode 100644 index 0000000000..75daff751d Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-76@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-83.5@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-83.5@2x.png new file mode 100644 index 0000000000..4a4070a38c Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPad-83.5@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@2x.png new file mode 100644 index 0000000000..fe258e8108 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@3x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@3x.png new file mode 100644 index 0000000000..da80786080 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@3x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@2x.png new file mode 100644 index 0000000000..6a4c2d88d6 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@3x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@3x.png new file mode 100644 index 0000000000..64fcb30d1e Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@3x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@2x.png new file mode 100644 index 0000000000..684e64feee Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@3x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@3x.png new file mode 100644 index 0000000000..668ad96fc3 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@3x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@2x.png new file mode 100644 index 0000000000..668ad96fc3 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@3x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@3x.png new file mode 100644 index 0000000000..f27bcc8667 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@3x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-launcher-40@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-launcher-40@2x.png new file mode 100644 index 0000000000..684e64feee Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-launcher-40@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-launcher-44@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-launcher-44@2x.png new file mode 100644 index 0000000000..18d0ccfe20 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-launcher-44@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-launcher-50@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-launcher-50@2x.png new file mode 100644 index 0000000000..8f8133474e Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-launcher-50@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-notification-24@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-notification-24@2x.png new file mode 100644 index 0000000000..e4e83a5bca Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-notification-24@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-notification-27.5@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-notification-27.5@2x.png new file mode 100644 index 0000000000..787b5fa282 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-notification-27.5@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-quicklook-108@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-quicklook-108@2x.png new file mode 100644 index 0000000000..508a5293bb Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-quicklook-108@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-quicklook-86@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-quicklook-86@2x.png new file mode 100644 index 0000000000..1cf5b63cca Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-quicklook-86@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-quicklook-98@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-quicklook-98@2x.png new file mode 100644 index 0000000000..5c757bb4d6 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-quicklook-98@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-settings-29@2x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-settings-29@2x.png new file mode 100644 index 0000000000..6a4c2d88d6 Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-settings-29@2x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-settings-29@3x.png b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-settings-29@3x.png new file mode 100644 index 0000000000..64fcb30d1e Binary files /dev/null and b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-watch-settings-29@3x.png differ diff --git a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index fb5454e222..67fbfbde7f 100644 --- a/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/swift/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,11 +1,191 @@ { "images": [ + { + "filename": "AppIcon-iPhone-20@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "AppIcon-iPhone-20@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" + }, + { + "filename": "AppIcon-iPhone-29@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-iPhone-29@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "AppIcon-iPhone-40@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "AppIcon-iPhone-40@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" + }, + { + "filename": "AppIcon-iPhone-60@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "AppIcon-iPhone-60@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "AppIcon-iPad-20.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" + }, + { + "filename": "AppIcon-iPad-20@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" + }, + { + "filename": "AppIcon-iPad-29.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" + }, + { + "filename": "AppIcon-iPad-29@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-iPad-40.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" + }, + { + "filename": "AppIcon-iPad-40@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" + }, + { + "filename": "AppIcon-iPad-76.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" + }, + { + "filename": "AppIcon-iPad-76@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" + }, + { + "filename": "AppIcon-iPad-83.5@2x.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" + }, { "filename": "AppIcon-iOS-1024.png", - "idiom": "universal", - "platform": "ios", + "idiom": "ios-marketing", + "scale": "1x", "size": "1024x1024" }, + { + "filename": "AppIcon-watch-notification-24@2x.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "24x24", + "subtype": "38mm" + }, + { + "filename": "AppIcon-watch-notification-27.5@2x.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "27.5x27.5", + "subtype": "42mm" + }, + { + "filename": "AppIcon-watch-settings-29@2x.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-watch-settings-29@3x.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "AppIcon-watch-launcher-40@2x.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "40x40", + "subtype": "38mm" + }, + { + "filename": "AppIcon-watch-launcher-44@2x.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "44x44", + "subtype": "40mm" + }, + { + "filename": "AppIcon-watch-launcher-50@2x.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "50x50", + "subtype": "44mm" + }, + { + "filename": "AppIcon-watch-quicklook-86@2x.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "86x86", + "subtype": "38mm" + }, + { + "filename": "AppIcon-watch-quicklook-98@2x.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "98x98", + "subtype": "42mm" + }, + { + "filename": "AppIcon-watch-quicklook-108@2x.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "108x108", + "subtype": "44mm" + }, { "filename": "AppIcon-mac-16.png", "idiom": "mac", diff --git a/apps/swift/Resources/Info-iOS.plist b/apps/swift/Resources/Info-iOS.plist index 387f691b9f..526d006653 100644 --- a/apps/swift/Resources/Info-iOS.plist +++ b/apps/swift/Resources/Info-iOS.plist @@ -5,9 +5,11 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - PackRat + $(PACKRAT_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) + CFBundleIconName + AppIcon CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion @@ -17,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleURLTypes @@ -46,7 +48,7 @@ CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) GOOGLE_IOS_CLIENT_ID 993694750638-f2pbg3e5tkt2btn9eih6c8ah8igal55m.apps.googleusercontent.com ITSAppUsesNonExemptEncryption @@ -58,8 +60,12 @@ NSAllowsLocalNetworking + NSCameraUsageDescription + This app requires access to your camera to let you take photos or scan items. NSLocationWhenInUseUsageDescription This app needs access to your location while you are using it. + NSPhotoLibraryUsageDescription + This app needs access to your photo library to let you upload or choose photos. PACKRAT_ENV $(PACKRAT_ENV) SENTRY_DSN @@ -75,6 +81,12 @@ UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown diff --git a/apps/swift/Resources/Info-macOS.plist b/apps/swift/Resources/Info-macOS.plist index 1c76dceb2a..8ba5115ee1 100644 --- a/apps/swift/Resources/Info-macOS.plist +++ b/apps/swift/Resources/Info-macOS.plist @@ -5,9 +5,11 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - PackRat + $(PACKRAT_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) + CFBundleIconName + AppIcon CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion @@ -17,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) NSAppTransportSecurity NSAllowsLocalNetworking diff --git a/apps/swift/Resources/Info-watchOS.plist b/apps/swift/Resources/Info-watchOS.plist index 0333ddd38e..6bc14b5237 100644 --- a/apps/swift/Resources/Info-watchOS.plist +++ b/apps/swift/Resources/Info-watchOS.plist @@ -5,9 +5,11 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - PackRat + $(PACKRAT_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) + CFBundleIconName + WatchAppIcon CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion @@ -17,14 +19,14 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + $(MARKETING_VERSION) CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption WKApplication WKCompanionAppBundleIdentifier - com.andrewbierman.packrat.swift + $(PACKRAT_COMPANION_BUNDLE_IDENTIFIER) diff --git a/apps/swift/Resources/PrivacyInfo.xcprivacy b/apps/swift/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..70f21ef389 --- /dev/null +++ b/apps/swift/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,76 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCrashData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + NSPrivacyCollectedDataTypeTracking + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePerformanceData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + NSPrivacyCollectedDataTypeTracking + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherDiagnosticData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + NSPrivacyCollectedDataTypeTracking + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/apps/swift/Resources/WatchAssets.xcassets/Contents.json b/apps/swift/Resources/WatchAssets.xcassets/Contents.json new file mode 100644 index 0000000000..74d6a722cf --- /dev/null +++ b/apps/swift/Resources/WatchAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-launcher-40@2x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-launcher-40@2x.png new file mode 100644 index 0000000000..684e64feee Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-launcher-40@2x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-launcher-44@2x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-launcher-44@2x.png new file mode 100644 index 0000000000..18d0ccfe20 Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-launcher-44@2x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-launcher-50@2x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-launcher-50@2x.png new file mode 100644 index 0000000000..8f8133474e Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-launcher-50@2x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-marketing-1024.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-marketing-1024.png new file mode 100644 index 0000000000..f3e00fc4e2 Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-marketing-1024.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-notification-24@2x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-notification-24@2x.png new file mode 100644 index 0000000000..e4e83a5bca Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-notification-24@2x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-notification-27.5@2x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-notification-27.5@2x.png new file mode 100644 index 0000000000..787b5fa282 Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-notification-27.5@2x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-quicklook-108@2x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-quicklook-108@2x.png new file mode 100644 index 0000000000..508a5293bb Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-quicklook-108@2x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-quicklook-86@2x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-quicklook-86@2x.png new file mode 100644 index 0000000000..1cf5b63cca Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-quicklook-86@2x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-quicklook-98@2x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-quicklook-98@2x.png new file mode 100644 index 0000000000..5c757bb4d6 Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-quicklook-98@2x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-settings-29@2x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-settings-29@2x.png new file mode 100644 index 0000000000..6a4c2d88d6 Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-settings-29@2x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-settings-29@3x.png b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-settings-29@3x.png new file mode 100644 index 0000000000..64fcb30d1e Binary files /dev/null and b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/AppIcon-watch-settings-29@3x.png differ diff --git a/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/Contents.json b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..6f0354dab8 --- /dev/null +++ b/apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset/Contents.json @@ -0,0 +1,92 @@ +{ + "images": [ + { + "filename": "AppIcon-watch-notification-24@2x.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "24x24", + "subtype": "38mm" + }, + { + "filename": "AppIcon-watch-notification-27.5@2x.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "27.5x27.5", + "subtype": "42mm" + }, + { + "filename": "AppIcon-watch-settings-29@2x.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "AppIcon-watch-settings-29@3x.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "AppIcon-watch-launcher-40@2x.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "40x40", + "subtype": "38mm" + }, + { + "filename": "AppIcon-watch-launcher-44@2x.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "44x44", + "subtype": "40mm" + }, + { + "filename": "AppIcon-watch-launcher-50@2x.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "50x50", + "subtype": "44mm" + }, + { + "filename": "AppIcon-watch-quicklook-86@2x.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "86x86", + "subtype": "38mm" + }, + { + "filename": "AppIcon-watch-quicklook-98@2x.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "98x98", + "subtype": "42mm" + }, + { + "filename": "AppIcon-watch-quicklook-108@2x.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "108x108", + "subtype": "44mm" + }, + { + "filename": "AppIcon-watch-marketing-1024.png", + "idiom": "watch-marketing", + "scale": "1x", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/apps/swift/Sources/PackRat/Features/Auth/AuthGateView.swift b/apps/swift/Sources/PackRat/Features/Auth/AuthGateView.swift index ab27db8fef..ad60f38d02 100644 --- a/apps/swift/Sources/PackRat/Features/Auth/AuthGateView.swift +++ b/apps/swift/Sources/PackRat/Features/Auth/AuthGateView.swift @@ -9,12 +9,18 @@ struct AuthGateView: View { var body: some View { Group { - if authManager.canUseApp { + if authManager.isRestoringSession { + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.secondary.opacity(0.08)) + } else if authManager.canUseApp { AppNavigation() } else { authContent } } + .animation(.spring(duration: 0.3), value: authManager.isRestoringSession) .animation(.spring(duration: 0.3), value: authManager.canUseApp) .animation(.spring(duration: 0.3), value: route) .onOpenURL { url in @@ -23,12 +29,6 @@ struct AuthGateView: View { return } #endif - let link = DeepLink.parse(url) - // Routing per destination is deferred — the scheme handler is wired here - // so deep links surface via Sentry breadcrumbs (once U9 lands) and the - // logs, even before each destination has a binding. This is enough to - // close the parity gap with Expo's `packrat://` scheme. - print("[DeepLink] received \(url) → \(link)") } } diff --git a/apps/swift/Sources/PackRat/Features/Auth/LoginView.swift b/apps/swift/Sources/PackRat/Features/Auth/LoginView.swift index 2b84135364..a32257171a 100644 --- a/apps/swift/Sources/PackRat/Features/Auth/LoginView.swift +++ b/apps/swift/Sources/PackRat/Features/Auth/LoginView.swift @@ -35,6 +35,9 @@ struct LoginView: View { SecureField("Password", text: $password) .textContentType(.password) + #if os(iOS) + .submitLabel(.go) + #endif .onSubmit { submit() } .padding(.horizontal, 14) .padding(.vertical, 12) @@ -43,7 +46,7 @@ struct LoginView: View { .authGroupedSurface() if let error { - InlineErrorView(message: error) + LoginInlineErrorView(message: error) } VStack(spacing: 12) { @@ -75,49 +78,51 @@ struct LoginView: View { .foregroundStyle(.tint) .font(.callout) - VStack(spacing: 10) { - HStack(spacing: 12) { - Divider() - Text("Or continue with") - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - Divider() - } + if AppFeatureFlags.enableOAuth { + VStack(spacing: 10) { + HStack(spacing: 12) { + Divider() + Text("Or continue with") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + Divider() + } - #if os(iOS) - Button { - signInWithGoogle() - } label: { - Label("Continue with Google", systemImage: "g.circle") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .controlSize(.large) - .disabled(isLoading) - .accessibilityIdentifier("auth_google") + #if os(iOS) + Button { + signInWithGoogle() + } label: { + Label("Continue with Google", systemImage: "g.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isLoading) + .accessibilityIdentifier("auth_google") - SignInWithAppleButton(.continue) { request in - request.requestedScopes = [.fullName, .email] - } onCompletion: { result in - signInWithApple(result) - } - .signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black) - .frame(height: 44) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .disabled(isLoading) - .accessibilityIdentifier("auth_apple") - #else - Button { - error = "Google sign-in is available in the iOS app. Use email sign-in on macOS for now." - } label: { - Label("Continue with Google", systemImage: "g.circle") - .frame(maxWidth: .infinity) + SignInWithAppleButton(.continue) { request in + request.requestedScopes = [.fullName, .email] + } onCompletion: { result in + signInWithApple(result) + } + .signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black) + .frame(height: 44) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .disabled(isLoading) + .accessibilityIdentifier("auth_apple") + #else + Button { + error = "Google sign-in is available in the iOS app. Use email sign-in on macOS for now." + } label: { + Label("Continue with Google", systemImage: "g.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + .accessibilityIdentifier("auth_google") + #endif } - .buttonStyle(.bordered) - .controlSize(.large) - .accessibilityIdentifier("auth_google") - #endif } } } @@ -176,6 +181,26 @@ struct LoginView: View { #endif } +private struct LoginInlineErrorView: View { + let message: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.red) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityIdentifier("login_error") + } +} + @ViewBuilder func authContainer(@ViewBuilder content: () -> Content) -> some View { #if os(macOS) diff --git a/apps/swift/Sources/PackRat/Features/Catalog/CatalogItemDetailView.swift b/apps/swift/Sources/PackRat/Features/Catalog/CatalogItemDetailView.swift index f561b43fa4..98f870caaa 100644 --- a/apps/swift/Sources/PackRat/Features/Catalog/CatalogItemDetailView.swift +++ b/apps/swift/Sources/PackRat/Features/Catalog/CatalogItemDetailView.swift @@ -49,15 +49,14 @@ struct CatalogItemDetailView: View { private var imageCarousel: some View { let images = item.images ?? [] if images.isEmpty { - Rectangle() - .fill(.fill.secondary) + CatalogArtwork(item: item, iconSize: 48) .frame(height: 260) - .overlay { Image(systemName: "photo").font(.largeTitle).foregroundStyle(.tertiary) } + .clipShape(Rectangle()) } else { TabView(selection: $selectedImageIndex) { ForEach(Array(images.enumerated()), id: \.offset) { index, url in RemoteImage(url: url, contentMode: .fit) { - Rectangle().fill(.fill.secondary) + CatalogArtwork(item: item, iconSize: 48) } .frame(height: 260) .tag(index) diff --git a/apps/swift/Sources/PackRat/Features/Catalog/CatalogView.swift b/apps/swift/Sources/PackRat/Features/Catalog/CatalogView.swift index 7fe468ba88..38315f6bf3 100644 --- a/apps/swift/Sources/PackRat/Features/Catalog/CatalogView.swift +++ b/apps/swift/Sources/PackRat/Features/Catalog/CatalogView.swift @@ -3,12 +3,19 @@ import NukeUI struct CatalogView: View { @Environment(AppState.self) private var appState + @Environment(AuthManager.self) private var authManager var body: some View { @Bindable var vm = appState.catalogVM return Group { - if vm.isLoading && vm.items.isEmpty { + if !authManager.isAuthenticated { + GuestLimitedView( + "Catalog Requires an Account", + subtitle: "Gear search syncs with PackRat's catalog service. Local packs and trips still work in guest mode.", + systemImage: "magnifyingglass" + ) + } else if vm.isLoading && vm.items.isEmpty { ProgressView("Searching gear…").frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = vm.error { ErrorView(error, retry: { await vm.search(reset: true) }) @@ -42,8 +49,16 @@ struct CatalogView: View { #else .searchable(text: $vm.searchText, prompt: "Search tents, packs, sleeping bags…") #endif - .onChange(of: vm.searchText) { vm.onSearchTextChanged() } - .onSubmit(of: .search) { Task { await vm.search(reset: true) } } + .onChange(of: vm.searchText) { + if authManager.isAuthenticated { + vm.onSearchTextChanged() + } + } + .onSubmit(of: .search) { + if authManager.isAuthenticated { + Task { await vm.search(reset: true) } + } + } .toolbar { if vm.isLoading && !vm.items.isEmpty { ToolbarItem(placement: .secondaryAction) { @@ -98,9 +113,7 @@ struct CatalogItemRow: View { private var rowContent: some View { HStack(spacing: 12) { RemoteImage(url: item.primaryImage, contentMode: .fill, cornerRadius: 8) { - RoundedRectangle(cornerRadius: 8) - .fill(.fill.secondary) - .overlay { Image(systemName: "photo").foregroundStyle(.tertiary) } + CatalogArtwork(item: item) } .frame(width: 56, height: 56) @@ -108,25 +121,21 @@ struct CatalogItemRow: View { Text(item.displayName) .font(.headline) .lineLimit(2) - HStack(spacing: 8) { - if let brand = item.displayBrand { - Text(brand).font(.caption.bold()).foregroundStyle(.tint) - } - if !item.displayWeight.isEmpty { - Label(item.displayWeight, systemImage: "scalemass") - .font(.caption).foregroundStyle(.secondary) - } - if let price = item.displayPrice { - Text(price).font(.caption.bold()).foregroundStyle(.green) - } + .fixedSize(horizontal: false, vertical: true) + ViewThatFits(in: .horizontal) { + metadataRow + metadataFlow } if let cats = item.categories, !cats.isEmpty { Text(cats.prefix(2).joined(separator: " · ")) - .font(.caption2).foregroundStyle(.tertiary) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) } } + .layoutPriority(1) - Spacer() + Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 4) { if let rating = item.ratingValue, rating > 0 { @@ -135,12 +144,15 @@ struct CatalogItemRow: View { Text(String(format: "%.1f", rating)) .font(.caption.monospacedDigit()).foregroundStyle(.secondary) } + .fixedSize(horizontal: true, vertical: false) } if !item.isInStock { Text("Out of Stock") .font(.caption2).foregroundStyle(.red) + .lineLimit(1) .padding(.horizontal, 6).padding(.vertical, 2) .background(.red.opacity(0.1), in: Capsule()) + .fixedSize(horizontal: true, vertical: false) } Button { @@ -160,6 +172,80 @@ struct CatalogItemRow: View { .padding(.horizontal, 14) .padding(.vertical, 10) } + + private var metadataRow: some View { + HStack(spacing: 8) { + metadataItems + } + .lineLimit(1) + } + + private var metadataFlow: some View { + VStack(alignment: .leading, spacing: 2) { + metadataItems + } + } + + @ViewBuilder + private var metadataItems: some View { + if let brand = item.displayBrand { + Text(brand) + .font(.caption.bold()) + .foregroundStyle(.tint) + .lineLimit(1) + } + if !item.displayWeight.isEmpty { + Label(item.displayWeight, systemImage: "scalemass") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if let price = item.displayPrice { + Text(price) + .font(.caption.bold()) + .foregroundStyle(.green) + .lineLimit(1) + } + } +} + +struct CatalogArtwork: View { + let item: CatalogItem + var iconSize: CGFloat = 22 + + var body: some View { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(tint.opacity(0.14)) + .overlay { + Image(systemName: symbol) + .font(.system(size: iconSize, weight: .semibold)) + .foregroundStyle(tint) + } + .accessibilityHidden(true) + } + + private var symbol: String { + let category = item.categories?.first?.lowercased() ?? "" + if category.contains("tent") || category.contains("shelter") { return "tent" } + if category.contains("pack") || category.contains("backpack") { return "backpack" } + if category.contains("sleep") || category.contains("bag") || category.contains("quilt") { return "moon.zzz" } + if category.contains("water") || category.contains("filter") || category.contains("hydration") { return "drop" } + if category.contains("cook") || category.contains("food") || category.contains("kitchen") { return "fork.knife" } + if category.contains("cloth") || category.contains("jacket") || category.contains("rain") { return "tshirt" } + if category.contains("light") || category.contains("lamp") { return "flashlight.on.fill" } + if category.contains("safety") || category.contains("first aid") { return "cross.case" } + return "mountain.2" + } + + private var tint: Color { + let category = item.categories?.first?.lowercased() ?? "" + if category.contains("water") || category.contains("filter") || category.contains("hydration") { return .cyan } + if category.contains("cook") || category.contains("food") || category.contains("kitchen") { return .orange } + if category.contains("safety") || category.contains("first aid") { return .red } + if category.contains("sleep") || category.contains("bag") || category.contains("quilt") { return .indigo } + if category.contains("cloth") || category.contains("jacket") || category.contains("rain") { return .teal } + return .blue + } } // MARK: - Add to Pack sheet @@ -233,8 +319,8 @@ struct AddCatalogItemToPackSheet: View { try await packsViewModel.addItem( to: packId, name: item.displayName, - weight: item.weight, - weightUnit: item.weightUnit.rawValue, + weight: item.weight ?? 0, + weightUnit: item.weightUnit?.rawValue ?? WeightUnit.g.rawValue, quantity: quantity, category: item.categories?.first, consumable: false, diff --git a/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift b/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift index 4df29fda53..abd983d834 100644 --- a/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift +++ b/apps/swift/Sources/PackRat/Features/Chat/ChatView.swift @@ -39,9 +39,11 @@ struct ChatView: View { } .navigationTitle("AI Assistant") .toolbar { - ToolbarItem(placement: .automatic) { - Button("Clear", systemImage: "trash") { viewModel.clearHistory() } - .disabled(!authManager.isAuthenticated || viewModel.messages.count <= 1) + if authManager.isAuthenticated { + ToolbarItem(placement: .automatic) { + Button("Clear", systemImage: "trash") { viewModel.clearHistory() } + .disabled(viewModel.messages.count <= 1) + } } } .keyboardDoneButton(isFocused: $isInputFocused) @@ -207,6 +209,9 @@ struct MessageBubble: View { insertion: .move(edge: isUser ? .trailing : .leading).combined(with: .opacity), removal: .opacity )) + .accessibilityElement(children: .ignore) + .accessibilityLabel(message.content.isEmpty ? (isUser ? "User message" : "Assistant is typing") : message.content) + .accessibilityIdentifier(isUser ? "chat_message_user" : "chat_message_assistant") } @ViewBuilder diff --git a/apps/swift/Sources/PackRat/Features/Chat/ChatViewModel.swift b/apps/swift/Sources/PackRat/Features/Chat/ChatViewModel.swift index dab5612dbf..79ad33b99c 100644 --- a/apps/swift/Sources/PackRat/Features/Chat/ChatViewModel.swift +++ b/apps/swift/Sources/PackRat/Features/Chat/ChatViewModel.swift @@ -91,27 +91,35 @@ final class ChatViewModel { private func appendToPlaceholder(id: UUID, text: String) { guard let idx = messages.firstIndex(where: { $0.id == id }) else { return } - messages[idx].content += text + var updated = messages[idx] + updated.content += text + messages[idx] = updated } private func addToolInvocation(to messageId: UUID, invocation: ToolInvocation) { guard let idx = messages.firstIndex(where: { $0.id == messageId }) else { return } - messages[idx].toolInvocations.append(invocation) + var updated = messages[idx] + updated.toolInvocations.append(invocation) + messages[idx] = updated } private func updateToolInput(id messageId: UUID, callId: String, data: Data) { guard let msgIdx = messages.firstIndex(where: { $0.id == messageId }), let toolIdx = messages[msgIdx].toolInvocations.firstIndex(where: { $0.id == callId }) else { return } - messages[msgIdx].toolInvocations[toolIdx].inputData = data + var updated = messages[msgIdx] + updated.toolInvocations[toolIdx].inputData = data + messages[msgIdx] = updated } private func updateToolOutput(id messageId: UUID, callId: String, data: Data) { guard let msgIdx = messages.firstIndex(where: { $0.id == messageId }), let toolIdx = messages[msgIdx].toolInvocations.firstIndex(where: { $0.id == callId }) else { return } - messages[msgIdx].toolInvocations[toolIdx].outputData = data - messages[msgIdx].toolInvocations[toolIdx].state = .complete + var updated = messages[msgIdx] + updated.toolInvocations[toolIdx].outputData = data + updated.toolInvocations[toolIdx].state = .complete + messages[msgIdx] = updated } } diff --git a/apps/swift/Sources/PackRat/Features/GearInventory/GearInventoryView.swift b/apps/swift/Sources/PackRat/Features/GearInventory/GearInventoryView.swift index f53ea6a802..7035c4fb79 100644 --- a/apps/swift/Sources/PackRat/Features/GearInventory/GearInventoryView.swift +++ b/apps/swift/Sources/PackRat/Features/GearInventory/GearInventoryView.swift @@ -75,12 +75,28 @@ struct GearInventoryView: View { .searchable(text: $searchText, prompt: "Search gear") .toolbar { ToolbarItem(placement: .primaryAction) { - Picker("Sort", selection: $sortOrder) { + Menu { ForEach(SortOrder.allCases, id: \.self) { order in - Text(order.rawValue).tag(order) + Button { + sortOrder = order + } label: { + Label( + order.rawValue, + systemImage: order == sortOrder ? "checkmark" : "circle" + ) + } } + } label: { + HStack(spacing: 4) { + Text(sortOrder.rawValue) + Image(systemName: "arrow.up.arrow.down") + } + .font(.subheadline) + .fixedSize() } - .pickerStyle(.menu) + .accessibilityIdentifier("gear_inventory_sort") + .accessibilityLabel("Sort gear inventory") + .accessibilityValue(sortOrder.rawValue) } } .task { await appState.packsVM.load(context: modelContext) } @@ -90,7 +106,7 @@ struct GearInventoryView: View { private var inventoryList: some View { List { Section { - HStack(spacing: 16) { + HStack(spacing: 10) { statChip(value: "\(allItems.count)", label: "Items", symbol: "archivebox.fill") statChip(value: formattedWeight(totalWeight), label: "Total", symbol: "scalemass.fill") statChip(value: "\(appState.packsVM.packs.count)", label: "Packs", symbol: "backpack.fill") @@ -116,10 +132,13 @@ struct GearInventoryView: View { .foregroundStyle(Color.accentColor) Text(value) .font(.subheadline.bold()) + .lineLimit(1) + .minimumScaleFactor(0.78) } Text(label) .font(.caption2) .foregroundStyle(.secondary) + .lineLimit(1) } .frame(maxWidth: .infinity) .padding(.vertical, 8) @@ -141,35 +160,52 @@ private struct GearItemRow: View { var body: some View { VStack(alignment: .leading, spacing: 4) { - HStack { + HStack(alignment: .firstTextBaseline, spacing: 8) { Text(item.name) .font(.body) + .lineLimit(2) + .layoutPriority(1) Spacer() if item.weightInGrams > 0 { Text(formattedWeight(item.weightInGrams * Double(item.quantity))) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } } - HStack(spacing: 8) { - if let cat = item.category { - Label(cat.capitalized, systemImage: "tag") - .font(.caption2) - .foregroundStyle(.secondary) + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { + metadataItems } - Label(item.packName, systemImage: "backpack") - .font(.caption2) - .foregroundStyle(.secondary) - if item.quantity > 1 { - Text("×\(item.quantity)") - .font(.caption2.bold()) - .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + metadataItems } } } .padding(.vertical, 2) } + @ViewBuilder + private var metadataItems: some View { + if let cat = item.category { + Label(cat.capitalized, systemImage: "tag") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Label(item.packName, systemImage: "backpack") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + if item.quantity > 1 { + Text("×\(item.quantity)") + .font(.caption2.bold()) + .foregroundStyle(Color.accentColor) + .lineLimit(1) + } + } + private func formattedWeight(_ grams: Double) -> String { if grams >= 1000 { return String(format: "%.1fkg", grams / 1000) } return String(format: "%.0fg", grams) diff --git a/apps/swift/Sources/PackRat/Features/Home/HomeView.swift b/apps/swift/Sources/PackRat/Features/Home/HomeView.swift index 04cb8b8844..4ef3218a1b 100644 --- a/apps/swift/Sources/PackRat/Features/Home/HomeView.swift +++ b/apps/swift/Sources/PackRat/Features/Home/HomeView.swift @@ -1,6 +1,8 @@ import SwiftUI struct HomeView: View { + var onOpenAssistant: (() -> Void)? + @Environment(AppState.self) private var appState @Environment(AuthManager.self) private var authManager @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -110,10 +112,13 @@ struct HomeView: View { VStack(alignment: .leading, spacing: 4) { Text(firstName.isEmpty ? greeting : "\(greeting), \(firstName)") .font(.title2.bold()) + .lineLimit(2) + .minimumScaleFactor(0.86) .accessibilityIdentifier("home_greeting") Text("Here's your outdoor dashboard") .font(.subheadline) .foregroundStyle(.secondary) + .lineLimit(2) } Spacer(minLength: 0) @@ -140,11 +145,14 @@ struct HomeView: View { Text(summaryTitle) .font(.headline) .foregroundStyle(.primary) + .lineLimit(2) Text(summarySubtitle) .font(.subheadline) .foregroundStyle(.secondary) + .lineLimit(3) .fixedSize(horizontal: false, vertical: true) } + .layoutPriority(1) Spacer(minLength: 0) } @@ -245,6 +253,8 @@ struct HomeView: View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(value) .font(.headline.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) Text(label) .font(.caption) .foregroundStyle(.secondary) @@ -307,7 +317,13 @@ struct HomeView: View { ) { appState.navItem = .packs }, HomeAction(title: "Trips", subtitle: upcomingTripsSubtitle, symbol: "map.fill", color: .green) { appState.navItem = .trips }, HomeAction(title: "Weather", subtitle: "Forecasts & alerts", symbol: "cloud.sun.fill", color: .cyan) { appState.navItem = .weather }, - HomeAction(title: "AI Assistant", subtitle: "Ask about gear & trips", symbol: "bubble.left.and.text.bubble.right", color: .purple) { appState.navItem = .chat }, + HomeAction(title: "AI Assistant", subtitle: "Ask about gear & trips", symbol: "bubble.left.and.text.bubble.right", color: .purple) { + if let onOpenAssistant { + onOpenAssistant() + } else { + appState.navItem = .chat + } + }, HomeAction(title: "AI Packs", subtitle: "Generate pack ideas", symbol: "sparkles", color: .purple) { appState.navItem = .aiPacks }, HomeAction(title: "Gear Inventory", subtitle: inventorySubtitle, symbol: "shippingbox.fill", color: .orange) { appState.navItem = .gearInventory }, HomeAction(title: "Season Suggestions", subtitle: "AI-powered packing tips", symbol: "leaf.fill", color: .mint) { showingSeasonSuggestions = true }, @@ -397,6 +413,8 @@ private struct SummaryActionButton: View { HStack(spacing: 6) { Image(systemName: symbol) Text(title) + .lineLimit(1) + .minimumScaleFactor(0.82) } .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) @@ -454,11 +472,13 @@ private struct HomeActionRow: View { Text(action.title) .font(.body) .foregroundStyle(.primary) + .lineLimit(1) Text(action.subtitle) .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) + .lineLimit(2) } + .layoutPriority(1) Spacer(minLength: 8) diff --git a/apps/swift/Sources/PackRat/Features/OfflineAI/FeatureFlag.swift b/apps/swift/Sources/PackRat/Features/OfflineAI/FeatureFlag.swift index 15865990a1..639b5a415a 100644 --- a/apps/swift/Sources/PackRat/Features/OfflineAI/FeatureFlag.swift +++ b/apps/swift/Sources/PackRat/Features/OfflineAI/FeatureFlag.swift @@ -16,7 +16,7 @@ extension Defaults.Keys { /// on-device LLM via MLX). When false, it uses `MockLocalLLMProvider` /// (canned responses). Defaults to `false` until the MLX integration is /// product-greenlit and the model bundle/download path is settled. - public static let useRealLocalLLM = Key("featureFlag.useRealLocalLLM", default: false) + public static let useRealLocalLLM = Key("featureFlag_useRealLocalLLM", default: false) } // MARK: - Provider factory @@ -26,8 +26,10 @@ extension Defaults.Keys { /// instantiating a concrete provider so flipping the flag at runtime swaps /// implementations on the next read. public enum LocalLLMProviderFactory { + private static let mlxProviderAvailable = false + public static func makeProvider() -> LocalLLMProvider { - if AppFeatureFlags.enableLocalAI && Defaults[.useRealLocalLLM] { + if AppFeatureFlags.enableLocalAI && mlxProviderAvailable && Defaults[.useRealLocalLLM] { return MLXLocalLLMProvider() } return MockLocalLLMProvider() diff --git a/apps/swift/Sources/PackRat/Features/OfflineAI/OfflineAIView.swift b/apps/swift/Sources/PackRat/Features/OfflineAI/OfflineAIView.swift index 6e95ac8c6b..2abc42e3c5 100644 --- a/apps/swift/Sources/PackRat/Features/OfflineAI/OfflineAIView.swift +++ b/apps/swift/Sources/PackRat/Features/OfflineAI/OfflineAIView.swift @@ -37,16 +37,15 @@ public struct OfflineAIView: View { private var providerSection: some View { Section("Provider") { Toggle("Use real on-device LLM (MLX)", isOn: $useRealLocalLLM) + .disabled(true) LabeledContent("Active provider") { - Text(useRealLocalLLM ? "MLXLocalLLMProvider (stub)" : "MockLocalLLMProvider") + Text("MockLocalLLMProvider") .font(.caption.monospaced()) .foregroundStyle(.secondary) } - if useRealLocalLLM { - Text("MLX provider is not yet wired. Submitting a prompt will surface a `notImplemented` error — this is expected until a follow-up PR adds the MLX dependency.") - .font(.caption) - .foregroundStyle(.secondary) - } + Text("The MLX provider is not available in this build. Offline AI uses the local mock provider until model packaging and runtime loading are production-ready.") + .font(.caption) + .foregroundStyle(.secondary) Text("Flag changes apply on next view appearance.") .font(.caption2) .foregroundStyle(.tertiary) diff --git a/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplateFormView.swift b/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplateFormView.swift index 4495a5200d..863b324e83 100644 --- a/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplateFormView.swift +++ b/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplateFormView.swift @@ -50,6 +50,7 @@ struct PackTemplateFormView: View { } } .pickerStyle(.menu) + .accessibilityIdentifier("template_category") } if let error { InlineErrorView(message: error).listRowBackground(Color.clear) diff --git a/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplatesView.swift b/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplatesView.swift index 1f72fd676e..f9d04d4772 100644 --- a/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplatesView.swift +++ b/apps/swift/Sources/PackRat/Features/PackTemplates/PackTemplatesView.swift @@ -8,11 +8,14 @@ struct PackTemplatesListView: View { @Bindable var viewModel: PackTemplatesViewModel @Binding var selectedId: String? var packsVM: PacksViewModel = PacksViewModel() + var showsGuestLimitInList = true @Environment(AuthManager.self) private var authManager @State private var showingNewTemplate = false #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass - private var isCompact: Bool { horizontalSizeClass == .compact } + private var isCompact: Bool { + horizontalSizeClass == .compact && UIDevice.current.userInterfaceIdiom == .phone + } #else private var isCompact: Bool { false } #endif @@ -20,40 +23,35 @@ struct PackTemplatesListView: View { var body: some View { Group { if !authManager.isAuthenticated { - #if os(macOS) - Color.clear - #else - GuestLimitedView( - "Templates Require an Account", - subtitle: "Pack templates sync with your account so they can be reused across devices.", - systemImage: "doc.on.doc" - ) - #endif + if showsGuestLimitInList { + GuestLimitedView( + "Templates Require an Account", + subtitle: "Pack templates sync with your account so they can be reused across devices.", + systemImage: "doc.on.doc" + ) + } else { + Color.clear + } } else if viewModel.isLoading && viewModel.templates.isEmpty { ProgressView("Loading templates…").frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = viewModel.error, viewModel.templates.isEmpty { ErrorView(error, retry: { await viewModel.load() }) - } else if viewModel.templates.isEmpty { - EmptyStateView( - "No Templates Yet", - subtitle: "Templates let you quickly populate a pack with a standard gear list", - systemImage: "doc.on.doc" - ) } else { templateList } } .navigationTitle("Pack Templates") - .searchable(text: $viewModel.searchText, prompt: "Search templates") + .packTemplateSearchable(text: $viewModel.searchText) .task { if authManager.isAuthenticated && viewModel.templates.isEmpty { await viewModel.load() } } .refreshable { if authManager.isAuthenticated { await viewModel.load() } } .toolbar { - ToolbarItem(placement: .primaryAction) { - Button("New Template", systemImage: "plus") { - showingNewTemplate = true + if authManager.isAuthenticated { + ToolbarItem(placement: .primaryAction) { + Button("New Template", systemImage: "plus") { + showingNewTemplate = true + } + .accessibilityIdentifier("templates_new_template_button") } - .accessibilityIdentifier("templates_new_template_button") - .disabled(!authManager.isAuthenticated) } } .sheet(isPresented: $showingNewTemplate) { @@ -65,7 +63,23 @@ struct PackTemplatesListView: View { private var templateList: some View { List(selection: $selectedId) { - if !viewModel.officialTemplates.isEmpty { + if viewModel.filteredTemplates.isEmpty { + Section { + if viewModel.searchText.isEmpty { + EmptyStateView( + "No Templates Yet", + subtitle: "Templates let you quickly populate a pack with a standard gear list", + systemImage: "doc.on.doc", + actionLabel: "New Template", + action: { showingNewTemplate = true } + ) + } else { + ContentUnavailableView.search(text: viewModel.searchText) + } + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else if !viewModel.officialTemplates.isEmpty { Section("Official") { ForEach(viewModel.officialTemplates) { t in templateRow(t) @@ -105,6 +119,21 @@ struct PackTemplatesListView: View { } } +private extension View { + @ViewBuilder + func packTemplateSearchable(text: Binding) -> some View { + #if os(iOS) + self.searchable( + text: text, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search templates" + ) + #else + self.searchable(text: text, prompt: "Search templates") + #endif + } +} + private struct TemplateRowView: View { let template: PackTemplate diff --git a/apps/swift/Sources/PackRat/Features/Packs/PackItemDetailView.swift b/apps/swift/Sources/PackRat/Features/Packs/PackItemDetailView.swift index 5998028c9f..81a2d67324 100644 --- a/apps/swift/Sources/PackRat/Features/Packs/PackItemDetailView.swift +++ b/apps/swift/Sources/PackRat/Features/Packs/PackItemDetailView.swift @@ -14,13 +14,12 @@ struct PackItemDetailView: View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 20) { + summarySection metadataSection if let notes = item.notes, !notes.isEmpty { notesSection(notes) } - if isLoadingSimilar || !similarItems.isEmpty { - similarSection - } + similarSection } .padding(.bottom, 24) } @@ -49,6 +48,87 @@ struct PackItemDetailView: View { // MARK: - Metadata + private var summarySection: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 14) { + itemImage + + VStack(alignment: .leading, spacing: 6) { + Text(item.name) + .font(.title2.bold()) + .fixedSize(horizontal: false, vertical: true) + if let description = item.description, !description.isEmpty { + Text(description) + .font(.body) + .foregroundStyle(.secondary) + } else { + Text("Pack item") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 0) + } + + VStack(spacing: 0) { + detailRow("Weight", value: item.displayWeight.isEmpty ? "Not set" : item.displayWeight, symbol: "scalemass") + detailRow("Quantity", value: "\(item.quantity)", symbol: "number") + detailRow("Category", value: item.category?.capitalized ?? "Uncategorized", symbol: "tag") + detailRow("Pack Weight", value: packWeightLabel, symbol: "backpack") + if item.catalogItemId != nil { + detailRow("Catalog Match", value: "Linked", symbol: "link") + } + if item.isAIGenerated == true { + detailRow("Source", value: "AI generated", symbol: "sparkles") + } + } + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + } + .padding(.horizontal) + } + + private var itemImage: some View { + ZStack { + RoundedRectangle(cornerRadius: 14) + .fill(.fill.secondary) + .frame(width: 72, height: 72) + + if let image = item.image, let url = URL(string: image) { + LazyImage(url: url) { state in + if let image = state.image { + image.resizable().scaledToFill() + } else { + Image(systemName: categorySymbol) + .font(.title2) + .foregroundStyle(.secondary) + } + } + .frame(width: 72, height: 72) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } else { + Image(systemName: categorySymbol) + .font(.title2) + .foregroundStyle(.secondary) + } + } + } + + private func detailRow(_ title: String, value: String, symbol: String) -> some View { + LabeledContent { + Text(value) + .foregroundStyle(.primary) + } label: { + Label(title, systemImage: symbol) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .overlay(alignment: .bottom) { + Divider().padding(.leading, 42) + } + } + private var metadataSection: some View { VStack(alignment: .leading, spacing: 12) { // Weight + quantity row @@ -94,6 +174,28 @@ struct PackItemDetailView: View { .padding(.horizontal) } + private var categorySymbol: String { + switch item.category?.lowercased() { + case "shelter": return "tent" + case "sleep": return "moon.zzz" + case "food", "kitchen": return "fork.knife" + case "clothing": return "tshirt" + case "water": return "drop" + case "safety": return "cross.case" + case "pack": return "backpack" + default: return "archivebox" + } + } + + private var packWeightLabel: String { + switch (item.worn, item.consumable) { + case (true, true): return "Worn consumable" + case (true, false): return "Worn on body" + case (false, true): return "Consumable" + case (false, false): return "Base weight" + } + } + private func metaChip(value: String, label: String, symbol: String, color: Color) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { @@ -150,6 +252,14 @@ struct PackItemDetailView: View { ProgressView() .frame(maxWidth: .infinity) .padding() + } else if similarItems.isEmpty { + ContentUnavailableView( + "No Similar Gear", + systemImage: "magnifyingglass", + description: Text("Catalog suggestions will appear when matching gear is available.") + ) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { diff --git a/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift b/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift index f5957d90cb..2205cc5cfc 100644 --- a/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift +++ b/apps/swift/Sources/PackRat/Features/Packs/PacksListView.swift @@ -30,7 +30,7 @@ struct PacksListView: View { categoryFilterBar Group { - if viewModel.isLoading && viewModel.packs.isEmpty && !isExplore { + if viewModel.isLoading && viewModel.packs.isEmpty && !viewModel.isCacheLoaded && !isExplore { ProgressView("Loading packs…").frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = viewModel.error, viewModel.packs.isEmpty, !isExplore { ErrorView(error, retry: { await viewModel.load(context: modelContext) }) @@ -70,7 +70,6 @@ struct PacksListView: View { Button("New Pack", systemImage: "plus") { showingCreateSheet = true } .accessibilityIdentifier("packs_new_pack_button") .keyboardShortcut("n", modifiers: .command) - .accessibilityIdentifier("new_pack_button") } if viewModel.isLoading || isLoadingPublic { ProgressView().controlSize(.small) @@ -196,8 +195,11 @@ private struct PackRowView: View { var body: some View { VStack(alignment: .leading, spacing: 4) { - HStack { - Text(pack.name).font(.headline) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(pack.name) + .font(.headline) + .lineLimit(2) + .layoutPriority(1) Spacer() if let total = pack.totalWeight, total > 0 { Text(pack.formattedWeight(total)) @@ -206,6 +208,7 @@ private struct PackRowView: View { .padding(.horizontal, 7) .padding(.vertical, 2) .background(.fill.tertiary, in: Capsule()) + .fixedSize(horizontal: true, vertical: false) } } HStack(spacing: 8) { @@ -213,14 +216,17 @@ private struct PackRowView: View { Label(cat.label, systemImage: cat.symbol) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } Text("\(pack.itemCount) item\(pack.itemCount == 1 ? "" : "s")") .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) if pack.isPublic == true { Image(systemName: "globe").font(.caption2).foregroundStyle(.tint) } } + .lineLimit(1) } .padding(.vertical, 2) } diff --git a/apps/swift/Sources/PackRat/Features/Packs/PacksViewModel.swift b/apps/swift/Sources/PackRat/Features/Packs/PacksViewModel.swift index 404de863a6..feedbb65b8 100644 --- a/apps/swift/Sources/PackRat/Features/Packs/PacksViewModel.swift +++ b/apps/swift/Sources/PackRat/Features/Packs/PacksViewModel.swift @@ -54,8 +54,8 @@ final class PacksViewModel { let cachedPacks = cached.compactMap { $0.toPack() } if !cachedPacks.isEmpty { packs = cachedPacks - isCacheLoaded = true } + isCacheLoaded = true } isLoading = packs.isEmpty diff --git a/apps/swift/Sources/PackRat/Features/SeasonSuggestions/SeasonSuggestionsView.swift b/apps/swift/Sources/PackRat/Features/SeasonSuggestions/SeasonSuggestionsView.swift index 8608cd40b3..c779824fe3 100644 --- a/apps/swift/Sources/PackRat/Features/SeasonSuggestions/SeasonSuggestionsView.swift +++ b/apps/swift/Sources/PackRat/Features/SeasonSuggestions/SeasonSuggestionsView.swift @@ -9,9 +9,13 @@ final class SeasonSuggestionsService: Sendable { init(api: APIClient = .shared) { self.api = api } func getSuggestions(location: String, date: String) async throws -> SeasonSuggestionsResponse { + if VisualSampleData.isEnabled { + return VisualSampleData.seasonSuggestions(location: location) + } + let endpoint = Endpoint( .post, - "/api/season-suggestions", + "/api/season-suggestions/", body: ["location": location, "date": date] ) return try await api.send(endpoint) @@ -109,6 +113,7 @@ struct SeasonSuggestionsView: View { TextField("e.g. Yosemite, Pacific Crest Trail…", text: $viewModel.location) .submitLabel(.go) .onSubmit { Task { await viewModel.load() } } + .accessibilityIdentifier("season_suggestions_location") } header: { Text("Destination") } footer: { @@ -127,6 +132,7 @@ struct SeasonSuggestionsView: View { } label: { Label("Get Suggestions", systemImage: "sparkles") } + .accessibilityIdentifier("season_suggestions_submit") .disabled(viewModel.location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isLoading) } } @@ -164,6 +170,7 @@ struct SeasonSuggestionsView: View { } } } + .accessibilityIdentifier("season_suggestions_results") #if os(iOS) .listStyle(.insetGrouped) #endif diff --git a/apps/swift/Sources/PackRat/Features/TrailConditions/TrailConditionsView.swift b/apps/swift/Sources/PackRat/Features/TrailConditions/TrailConditionsView.swift index 35584838db..341bc6d0f5 100644 --- a/apps/swift/Sources/PackRat/Features/TrailConditions/TrailConditionsView.swift +++ b/apps/swift/Sources/PackRat/Features/TrailConditions/TrailConditionsView.swift @@ -5,11 +5,14 @@ import SwiftUI struct TrailConditionsListView: View { @Bindable var viewModel: TrailConditionsViewModel @Binding var selectedId: String? + var showsGuestLimitInList = true @Environment(AuthManager.self) private var authManager @State private var showingSubmitSheet = false #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass - private var isCompact: Bool { horizontalSizeClass == .compact } + private var isCompact: Bool { + horizontalSizeClass == .compact && UIDevice.current.userInterfaceIdiom == .phone + } #else private var isCompact: Bool { false } #endif @@ -17,15 +20,15 @@ struct TrailConditionsListView: View { var body: some View { Group { if !authManager.isAuthenticated { - #if os(macOS) - Color.clear - #else - GuestLimitedView( - "Trail Reports Require an Account", - subtitle: "Community trail conditions are shared through your PackRat account.", - systemImage: "figure.hiking" - ) - #endif + if showsGuestLimitInList { + GuestLimitedView( + "Trail Reports Require an Account", + subtitle: "Community trail conditions are shared through your PackRat account.", + systemImage: "figure.hiking" + ) + } else { + Color.clear + } } else if viewModel.isLoading && viewModel.reports.isEmpty { ProgressView("Loading reports…").frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = viewModel.error, viewModel.reports.isEmpty { @@ -45,10 +48,11 @@ struct TrailConditionsListView: View { .navigationTitle("Trail Conditions") .searchable(text: $viewModel.searchText, prompt: "Search trails") .toolbar { - ToolbarItem(placement: .primaryAction) { - Button("Submit Report", systemImage: "plus") { showingSubmitSheet = true } - .accessibilityIdentifier("trail_conditions_submit_report_button") - .disabled(!authManager.isAuthenticated) + if authManager.isAuthenticated { + ToolbarItem(placement: .primaryAction) { + Button("Submit Report", systemImage: "plus") { showingSubmitSheet = true } + .accessibilityIdentifier("trail_conditions_submit_report_button") + } } } .task { if authManager.isAuthenticated && viewModel.reports.isEmpty { await viewModel.load() } } diff --git a/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift b/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift index ae73fe78d7..0f06762fe9 100644 --- a/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift +++ b/apps/swift/Sources/PackRat/Features/Trips/TripsListView.swift @@ -16,7 +16,7 @@ struct TripsListView: View { var body: some View { Group { - if viewModel.isLoading && viewModel.trips.isEmpty { + if viewModel.isLoading && viewModel.trips.isEmpty && !viewModel.isCacheLoaded { ProgressView("Loading trips…").frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = viewModel.error, viewModel.trips.isEmpty { ErrorView(error, retry: { await viewModel.load(context: modelContext) }) @@ -42,7 +42,6 @@ struct TripsListView: View { Button("Plan Trip", systemImage: "plus") { showingCreateSheet = true } .accessibilityIdentifier("trips_plan_trip_button") .keyboardShortcut("n", modifiers: [.command, .shift]) - .accessibilityIdentifier("plan_trip_button") } } .task { await viewModel.load(context: modelContext) } @@ -129,26 +128,52 @@ struct TripsListView: View { } private struct TripRowView: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + let trip: Trip var body: some View { VStack(alignment: .leading, spacing: 4) { - Text(trip.name).font(.headline) - HStack(spacing: 10) { - if let loc = trip.location?.name { - Label(loc, systemImage: "mappin") - .font(.caption).foregroundStyle(.secondary) - } - if !trip.dateRange.isEmpty { - Label(trip.dateRange, systemImage: "calendar") - .font(.caption).foregroundStyle(.secondary) + Text(trip.name) + .font(.headline) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + if horizontalSizeClass == .compact { + VStack(alignment: .leading, spacing: 3) { + metadataItems } - if trip.packId != nil { - Label("Pack linked", systemImage: "backpack") - .font(.caption).foregroundStyle(.secondary) + .lineLimit(1) + } else { + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { + metadataItems + } + VStack(alignment: .leading, spacing: 3) { + metadataItems + } } + .lineLimit(1) } } .padding(.vertical, 2) } + + @ViewBuilder + private var metadataItems: some View { + if let loc = trip.location?.name { + Label(loc, systemImage: "mappin") + .font(.caption) + .foregroundStyle(.secondary) + } + if !trip.dateRange.isEmpty { + Label(trip.dateRange, systemImage: "calendar") + .font(.caption) + .foregroundStyle(.secondary) + } + if trip.packId != nil { + Label("Pack linked", systemImage: "backpack") + .font(.caption) + .foregroundStyle(.secondary) + } + } } diff --git a/apps/swift/Sources/PackRat/Features/Trips/TripsViewModel.swift b/apps/swift/Sources/PackRat/Features/Trips/TripsViewModel.swift index f3fb8290e2..20edd2ed26 100644 --- a/apps/swift/Sources/PackRat/Features/Trips/TripsViewModel.swift +++ b/apps/swift/Sources/PackRat/Features/Trips/TripsViewModel.swift @@ -66,8 +66,8 @@ final class TripsViewModel { let cachedTrips = cached.compactMap { $0.toTrip() } if !cachedTrips.isEmpty { trips = cachedTrips - isCacheLoaded = true } + isCacheLoaded = true } isLoading = trips.isEmpty diff --git a/apps/swift/Sources/PackRat/Features/Weather/ForecastRow.swift b/apps/swift/Sources/PackRat/Features/Weather/ForecastRow.swift index 805427ab69..559acd35f3 100644 --- a/apps/swift/Sources/PackRat/Features/Weather/ForecastRow.swift +++ b/apps/swift/Sources/PackRat/Features/Weather/ForecastRow.swift @@ -1,7 +1,27 @@ import SwiftUI +enum WeatherTemperatureDisplay { + static func format( + celsius: Double?, + fahrenheit: Double?, + unit: AppPreferences.TemperatureUnit + ) -> String { + let value: Double? + switch unit { + case .celsius: + value = celsius ?? fahrenheit.map { ($0 - 32) * 5 / 9 } + case .fahrenheit: + value = fahrenheit ?? celsius.map { ($0 * 9 / 5) + 32 } + } + + guard let value else { return "—" } + return "\(Int(value.rounded()))\(unit.label)" + } +} + struct ForecastRow: View { let day: ForecastDay + let temperatureUnit: AppPreferences.TemperatureUnit var body: some View { HStack(spacing: 12) { @@ -33,14 +53,24 @@ struct ForecastRow: View { .foregroundStyle(.blue) } - Text(String(format: "%.0f°", day.day?.maxtempF ?? 0)) + Text(WeatherTemperatureDisplay.format( + celsius: day.day?.maxtempC, + fahrenheit: day.day?.maxtempF, + unit: temperatureUnit + )) .font(.callout.bold()) - .frame(width: 36, alignment: .trailing) + .frame(width: 44, alignment: .trailing) + .accessibilityIdentifier("weather_forecast_high_\(day.id)") - Text(String(format: "%.0f°", day.day?.mintempF ?? 0)) + Text(WeatherTemperatureDisplay.format( + celsius: day.day?.mintempC, + fahrenheit: day.day?.mintempF, + unit: temperatureUnit + )) .font(.callout) .foregroundStyle(.secondary) - .frame(width: 36, alignment: .trailing) + .frame(width: 44, alignment: .trailing) + .accessibilityIdentifier("weather_forecast_low_\(day.id)") } } .padding(.horizontal) diff --git a/apps/swift/Sources/PackRat/Features/Weather/WeatherView.swift b/apps/swift/Sources/PackRat/Features/Weather/WeatherView.swift index b7ddadab76..43773fe477 100644 --- a/apps/swift/Sources/PackRat/Features/Weather/WeatherView.swift +++ b/apps/swift/Sources/PackRat/Features/Weather/WeatherView.swift @@ -1,10 +1,12 @@ import SwiftUI struct WeatherView: View { + @Environment(AuthManager.self) private var authManager @Bindable var viewModel: WeatherViewModel @State private var showingAlerts = false @State private var showingAlertPreferences = false @State private var isSearchPresented = false + @AppStorage("temperatureUnit") private var temperatureUnit: AppPreferences.TemperatureUnit = .fahrenheit @AppStorage("speedUnit") private var speedUnit: SpeedUnit = .mph /// Renders an API wind value (always mph) in the user's preferred unit. @@ -17,45 +19,55 @@ struct WeatherView: View { } var body: some View { - List { - searchStateContent - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - - if !viewModel.savedLocations.isEmpty && viewModel.searchText.isEmpty && viewModel.searchResults.isEmpty { - savedLocationsSection - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } - - if let forecast = viewModel.forecast { - forecastContent(forecast) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } else if viewModel.isLoadingForecast { - ProgressView("Loading forecast…") - .frame(maxWidth: .infinity) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } else if let error = viewModel.forecastError { - ErrorView(error, retry: { await viewModel.refresh() }) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } else if viewModel.savedLocations.isEmpty { - EmptyStateView( - "No Saved Locations", - subtitle: "Search for a city or ZIP code and save it to track the weather", + Group { + if !authManager.isAuthenticated { + GuestLimitedView( + "Weather Requires an Account", + subtitle: "Forecasts and alerts come from PackRat's weather service. Local packs and trips still work in guest mode.", systemImage: "cloud.sun" ) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) + } else { + List { + searchStateContent + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + + if !viewModel.savedLocations.isEmpty && viewModel.searchText.isEmpty && viewModel.searchResults.isEmpty { + savedLocationsSection + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + + if let forecast = viewModel.forecast { + forecastContent(forecast) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } else if viewModel.isLoadingForecast { + ProgressView("Loading forecast…") + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } else if let error = viewModel.forecastError { + ErrorView(error, retry: { await viewModel.refresh() }) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } else if viewModel.savedLocations.isEmpty { + EmptyStateView( + "No Saved Locations", + subtitle: "Search for a city or ZIP code and save it to track the weather", + systemImage: "cloud.sun" + ) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + } + #if os(iOS) + .listStyle(.insetGrouped) + #else + .listStyle(.inset) + #endif } } - #if os(iOS) - .listStyle(.insetGrouped) - #else - .listStyle(.inset) - #endif .navigationTitle("Weather") #if os(iOS) .searchable( @@ -67,32 +79,42 @@ struct WeatherView: View { #else .searchable(text: $viewModel.searchText, isPresented: $isSearchPresented, prompt: "Search locations…") #endif - .onChange(of: viewModel.searchText) { viewModel.onSearchTextChanged() } - .refreshable { await viewModel.refresh() } + .onChange(of: viewModel.searchText) { + if authManager.isAuthenticated { + viewModel.onSearchTextChanged() + } + } + .refreshable { + if authManager.isAuthenticated { + await viewModel.refresh() + } + } .toolbar { - ToolbarItem(placement: alertsToolbarPlacement) { - Button { - showingAlerts = true - } label: { - Label("Alerts", systemImage: activeAlerts.isEmpty ? "bell" : "bell.badge.fill") - .foregroundStyle(activeAlerts.isEmpty ? Color.secondary : Color.red) + if authManager.isAuthenticated { + ToolbarItem(placement: alertsToolbarPlacement) { + Button { + showingAlerts = true + } label: { + Label("Alerts", systemImage: activeAlerts.isEmpty ? "bell" : "bell.badge.fill") + .foregroundStyle(activeAlerts.isEmpty ? Color.secondary : Color.red) + } + .disabled(viewModel.forecast == nil) + .accessibilityLabel("Alerts") + .accessibilityIdentifier("weather_alerts_button") } - .disabled(viewModel.forecast == nil) - .accessibilityLabel("Alerts") - .accessibilityIdentifier("weather_alerts_button") - } - if viewModel.isLoadingForecast && viewModel.forecast != nil { - ToolbarItem(placement: .secondaryAction) { - ProgressView().controlSize(.small) + if viewModel.isLoadingForecast && viewModel.forecast != nil { + ToolbarItem(placement: .secondaryAction) { + ProgressView().controlSize(.small) + } } - } - ToolbarItem(placement: preferencesToolbarPlacement) { - NavigationLink { - WeatherAlertPreferencesView() - } label: { - Label("Alert Preferences", systemImage: "slider.horizontal.3") + ToolbarItem(placement: preferencesToolbarPlacement) { + NavigationLink { + WeatherAlertPreferencesView() + } label: { + Label("Alert Preferences", systemImage: "slider.horizontal.3") + } + .accessibilityIdentifier("weather_alert_preferences_button") } - .accessibilityIdentifier("weather_alert_preferences_button") } } .sheet(isPresented: $showingAlerts) { @@ -236,7 +258,7 @@ struct WeatherView: View { .padding(.horizontal, 4) VStack(spacing: 0) { ForEach(days) { day in - ForecastRow(day: day) + ForecastRow(day: day, temperatureUnit: temperatureUnit) if day.id != days.last?.id { Divider().padding(.horizontal) } @@ -264,14 +286,13 @@ struct WeatherView: View { .symbolRenderingMode(.multicolor) } - HStack(alignment: .lastTextBaseline, spacing: 4) { - Text(String(format: "%.0f°", current.tempF ?? 0)) - .font(.system(size: 64, weight: .thin)) - Text("F") - .font(.title3) - .foregroundStyle(.secondary) - .padding(.bottom, 8) - } + Text(WeatherTemperatureDisplay.format( + celsius: current.tempC, + fahrenheit: current.tempF, + unit: temperatureUnit + )) + .font(.system(size: 64, weight: .thin)) + .accessibilityIdentifier("weather_current_temperature") if let condition = current.condition?.text { Text(condition) @@ -282,7 +303,16 @@ struct WeatherView: View { Divider() HStack(spacing: 0) { - weatherDetail("Feels Like", value: String(format: "%.0f°", current.feelslikeF ?? 0), symbol: "thermometer") + weatherDetail( + "Feels Like", + value: WeatherTemperatureDisplay.format( + celsius: current.feelslikeC, + fahrenheit: current.feelslikeF, + unit: temperatureUnit + ), + symbol: "thermometer" + ) + .accessibilityIdentifier("weather_feels_like_temperature") Divider().frame(height: 32) weatherDetail("Humidity", value: "\(current.humidity ?? 0)%", symbol: "humidity") Divider().frame(height: 32) diff --git a/apps/swift/Sources/PackRat/Features/Weather/WeatherViewModel.swift b/apps/swift/Sources/PackRat/Features/Weather/WeatherViewModel.swift index c03c4b229c..9f38f220c6 100644 --- a/apps/swift/Sources/PackRat/Features/Weather/WeatherViewModel.swift +++ b/apps/swift/Sources/PackRat/Features/Weather/WeatherViewModel.swift @@ -16,15 +16,16 @@ final class WeatherViewModel { var searchError: String? var forecastError: String? - private let service: WeatherService + private let service: any WeatherServicing private var searchTask: Task? - init(service: WeatherService = .shared) { + init(service: any WeatherServicing = WeatherService.shared, loadPersistedState: Bool = true) { self.service = service if VisualSampleData.isUITestFixturesEnabled { UserDefaults.standard.removeObject(forKey: savedLocationsKey) UserDefaults.standard.removeObject(forKey: activeLocationKey) } + guard loadPersistedState else { return } guard !VisualSampleData.isScreenshotCapture else { return } loadSavedLocations() if let active = savedLocations.first(where: { $0.id == UserDefaults.standard.integer(forKey: activeLocationKey) }) @@ -106,15 +107,15 @@ final class WeatherViewModel { searchResults = [] searchText = "" UserDefaults.standard.set(location.id, forKey: activeLocationKey) - await loadForecast(for: location.id) + await loadForecast(for: location) } - func loadForecast(for locationId: Int) async { + func loadForecast(for location: WeatherLocation) async { if (VisualSampleData.isEnabled || VisualSampleData.isUITestFixturesEnabled), - let location = selectedLocation ?? VisualSampleData.weatherLocations.first(where: { $0.id == locationId }) { + let selected = selectedLocation ?? VisualSampleData.weatherLocations.first(where: { $0.id == location.id }) { isLoadingForecast = false forecastError = nil - forecast = VisualSampleData.weatherForecast(for: location) + forecast = VisualSampleData.weatherForecast(for: selected) return } @@ -128,14 +129,18 @@ final class WeatherViewModel { forecastError = nil defer { isLoadingForecast = false } do { - forecast = try await service.getForecast(locationId: locationId) + forecast = try await service.getForecast(locationId: location.id) } catch { - forecastError = error.localizedDescription + do { + forecast = try await service.getForecast(query: location.displayName) + } catch { + forecastError = error.localizedDescription + } } } func refresh() async { - guard let id = selectedLocation?.id else { return } - await loadForecast(for: id) + guard let location = selectedLocation else { return } + await loadForecast(for: location) } } diff --git a/apps/swift/Sources/PackRat/Models/Catalog.swift b/apps/swift/Sources/PackRat/Models/Catalog.swift index 6445b9c54e..8eaf1c6959 100644 --- a/apps/swift/Sources/PackRat/Models/Catalog.swift +++ b/apps/swift/Sources/PackRat/Models/Catalog.swift @@ -8,7 +8,7 @@ extension CatalogItem { var displayBrand: String? { brand?.nilIfEmpty } var displayWeight: String { - guard weight > 0 else { return "" } + guard let weight, weight > 0, let weightUnit else { return "" } return String(format: "%.0f %@", weight, weightUnit.rawValue) } @@ -23,16 +23,25 @@ extension CatalogItem { // MARK: - Search response with flexible decoding // The search endpoint may return {items, page, limit, total} or a plain array. -struct CatalogSearchResponse: Codable, Sendable { +struct CatalogSearchResponse: Decodable, Sendable { let items: [CatalogItem] let total: Int? let page: Int? let limit: Int? + private enum CodingKeys: String, CodingKey { + case items + case total + case totalCount + case page + case limit + } + init(from decoder: Decoder) throws { if let container = try? decoder.container(keyedBy: CodingKeys.self) { items = (try? container.decode([CatalogItem].self, forKey: .items)) ?? [] - total = try? container.decodeIfPresent(Int.self, forKey: .total) + total = (try? container.decodeIfPresent(Int.self, forKey: .total)) + ?? (try? container.decodeIfPresent(Int.self, forKey: .totalCount)) page = try? container.decodeIfPresent(Int.self, forKey: .page) limit = try? container.decodeIfPresent(Int.self, forKey: .limit) } else if let arr = try? [CatalogItem](from: decoder) { diff --git a/apps/swift/Sources/PackRat/Models/Generated.swift b/apps/swift/Sources/PackRat/Models/Generated.swift index babdf09484..ce96a93447 100644 --- a/apps/swift/Sources/PackRat/Models/Generated.swift +++ b/apps/swift/Sources/PackRat/Models/Generated.swift @@ -168,8 +168,8 @@ struct CatalogItem: Codable, Identifiable, Sendable { let name: String let productUrl: String let sku: String - let weight: Double - let weightUnit: WeightUnit + let weight: Double? + let weightUnit: WeightUnit? let description: String? let categories: [String]? let images: [String]? diff --git a/apps/swift/Sources/PackRat/Navigation/AppNavigation.swift b/apps/swift/Sources/PackRat/Navigation/AppNavigation.swift index a7fd8b291c..69fd9f661f 100644 --- a/apps/swift/Sources/PackRat/Navigation/AppNavigation.swift +++ b/apps/swift/Sources/PackRat/Navigation/AppNavigation.swift @@ -104,6 +104,14 @@ struct AppNavigation: View { #endif var body: some View { + navigationBody + .onOpenURL { url in + appState.apply(DeepLink.parse(url)) + } + } + + @ViewBuilder + private var navigationBody: some View { #if os(iOS) if horizontalSizeClass == .compact { phoneLayout @@ -199,9 +207,18 @@ struct AppNavigation: View { case .trips: TripsListView(viewModel: appState.tripsVM, selectedId: $state.selectedTripId) case .templates: - PackTemplatesListView(viewModel: appState.templatesVM, selectedId: $state.selectedTemplateId, packsVM: appState.packsVM) + PackTemplatesListView( + viewModel: appState.templatesVM, + selectedId: $state.selectedTemplateId, + packsVM: appState.packsVM, + showsGuestLimitInList: false + ) case .trailConditions: - TrailConditionsListView(viewModel: appState.trailConditionsVM, selectedId: $state.selectedReportId) + TrailConditionsListView( + viewModel: appState.trailConditionsVM, + selectedId: $state.selectedReportId, + showsGuestLimitInList: false + ) default: EmptyView() } @@ -293,7 +310,10 @@ struct AppNavigation: View { return TabView(selection: $phoneTab) { NavigationStack(path: $phoneHomePath) { - phoneContentView(.home) + HomeView { + phoneHomePath.append(.chat) + } + .environment(appState) .navigationTitle(NavItem.home.label) .navigationDestination(for: NavItem.self) { item in phoneContentView(item) @@ -347,7 +367,9 @@ struct AppNavigation: View { } .onChange(of: phoneHomePath) { _, path in if let item = path.last { - state.navItem = item + if item != .chat { + state.navItem = item + } } else if phoneTab == .home { state.navItem = .home } diff --git a/apps/swift/Sources/PackRat/Navigation/DeepLinkRouting.swift b/apps/swift/Sources/PackRat/Navigation/DeepLinkRouting.swift new file mode 100644 index 0000000000..11022b141a --- /dev/null +++ b/apps/swift/Sources/PackRat/Navigation/DeepLinkRouting.swift @@ -0,0 +1,31 @@ +import Foundation + +@MainActor +extension AppState { + @discardableResult + func apply(_ deepLink: DeepLink) -> Bool { + switch deepLink { + case .home: + navItem = .home + return true + case .pack(let id): + navItem = .packs + selectedPackId = id + return true + case .trip(let id): + guard NavItem.trips.isFeatureEnabled else { return false } + navItem = .trips + selectedTripId = id + return true + case .feed: + guard NavItem.feed.isFeatureEnabled else { return false } + navItem = .feed + return true + case .weather: + navItem = .weather + return true + case .unknown: + return false + } + } +} diff --git a/apps/swift/Sources/PackRat/Network/APIClient.swift b/apps/swift/Sources/PackRat/Network/APIClient.swift index 7b60c6d02e..f24cd24c0c 100644 --- a/apps/swift/Sources/PackRat/Network/APIClient.swift +++ b/apps/swift/Sources/PackRat/Network/APIClient.swift @@ -100,6 +100,14 @@ actor APIClient { return try await execute(request, as: T.self) } + func sendData(_ endpoint: some APIEndpoint) async throws -> Data { + let request = try buildRequest(endpoint, sessionToken: KeychainService.shared.sessionToken) + let (data, response) = try await dataWithTransientRetry(for: request) + captureSessionTokenIfPresent(response) + try validateStatus(response, data: data) + return data + } + func sendDiscarding(_ endpoint: some APIEndpoint) async throws { let request = try buildRequest(endpoint, sessionToken: KeychainService.shared.sessionToken) let (data, response) = try await dataWithTransientRetry(for: request) @@ -247,10 +255,14 @@ actor APIClient { guard let http = response as? HTTPURLResponse else { throw PackRatError.unknown } switch http.statusCode { case 200...299: return - case 401: throw PackRatError.unauthorized + case 401: + if let message = APIErrorBody.decodeMessage(from: data), !message.isEmpty { + throw PackRatError.httpError(statusCode: http.statusCode, message: message) + } + throw PackRatError.unauthorized case 404: throw PackRatError.notFound default: - let message = (try? JSONDecoder().decode(APIErrorBody.self, from: data))?.error + let message = APIErrorBody.decodeMessage(from: data) throw PackRatError.httpError(statusCode: http.statusCode, message: message) } } @@ -272,4 +284,14 @@ actor APIClient { private struct APIErrorBody: Decodable { let error: String? + let message: String? + let code: String? + + static func decodeMessage(from data: Data) -> String? { + guard let body = try? JSONDecoder().decode(Self.self, from: data) else { + return nil + } + + return body.message ?? body.error ?? body.code + } } diff --git a/apps/swift/Sources/PackRat/Network/AuthManager.swift b/apps/swift/Sources/PackRat/Network/AuthManager.swift index 7dce5ee13d..d97e581301 100644 --- a/apps/swift/Sources/PackRat/Network/AuthManager.swift +++ b/apps/swift/Sources/PackRat/Network/AuthManager.swift @@ -10,6 +10,7 @@ import UIKit final class AuthManager { var currentUser: User? var isGuest = false + var isRestoringSession = false var isAuthenticated: Bool { currentUser != nil } var canUseApp: Bool { isAuthenticated || isGuest } @@ -25,11 +26,13 @@ final class AuthManager { UserDefaults.standard.removeObject(forKey: "current_user") UserDefaults.standard.removeObject(forKey: skippedLoginKey) } - if ProcessInfo.processInfo.arguments.contains("--seed-e2e-auth") { + if ProcessInfo.processInfo.arguments.contains("--seed-e2e-auth"), + Self.e2eLoginSeedAllowed { seedE2EAuthenticatedUser() return } loadStoredUser() + restoreStoredSessionIfNeeded() } // MARK: - Auth Actions @@ -40,6 +43,9 @@ final class AuthManager { /// braces guarantee for tests / mock transports. func login(email: String, password: String) async throws { if seedE2ELoginIfAllowed(email: email, password: password) { + await MainActor.run { + finishSeededE2ELogin() + } return } @@ -335,6 +341,32 @@ final class AuthManager { SentryConfig.setUser(id: user.id, email: user.email) } + private func restoreStoredSessionIfNeeded() { + guard currentUser == nil, + !isGuest, + KeychainService.shared.sessionToken != nil + else { + return + } + + isRestoringSession = true + Task { + do { + try await refreshProfile() + } catch PackRatError.unauthorized { + await MainActor.run { + signOut() + } + } catch { + // Preserve the token for transient network failures. The app + // can still recover on the next launch or explicit refresh. + } + await MainActor.run { + isRestoringSession = false + } + } + } + private func seedE2EAuthenticatedUser() { let environment = ProcessInfo.processInfo.environment let email = environment["PACKRAT_E2E_EMAIL"] ?? "e2e@packrat.test" @@ -351,9 +383,10 @@ final class AuthManager { updatedAt: nil ) - KeychainService.shared.saveSessionToken( - environment["PACKRAT_E2E_SESSION_TOKEN"] ?? "packrat-e2e-session" - ) + let sessionToken = environment["PACKRAT_E2E_SESSION_TOKEN"] + .flatMap { $0.isEmpty ? nil : $0 } + ?? "packrat-e2e-session" + KeychainService.shared.saveSessionToken(sessionToken) persistUser(user) isGuest = false currentUser = user @@ -363,6 +396,7 @@ final class AuthManager { private func seedE2ELoginIfAllowed(email: String, password: String) -> Bool { let environment = ProcessInfo.processInfo.environment guard ProcessInfo.processInfo.arguments.contains("--allow-e2e-login-seed"), + Self.e2eLoginSeedAllowed, let expectedEmail = environment["PACKRAT_E2E_EMAIL"], let expectedPassword = environment["PACKRAT_E2E_PASSWORD"], email.caseInsensitiveCompare(expectedEmail) == .orderedSame, @@ -371,9 +405,17 @@ final class AuthManager { return false } - seedE2EAuthenticatedUser() return true } + + private func finishSeededE2ELogin() { + seedE2EAuthenticatedUser() + } + + private static var e2eLoginSeedAllowed: Bool { + let value = ProcessInfo.processInfo.environment["PACKRAT_E2E_ALLOW_LOGIN_SEED"] ?? "" + return value == "1" || value.caseInsensitiveCompare("true") == .orderedSame + } } enum SocialProvider: String { diff --git a/apps/swift/Sources/PackRat/Network/KeychainService.swift b/apps/swift/Sources/PackRat/Network/KeychainService.swift index 454ef9895f..febc1eb89f 100644 --- a/apps/swift/Sources/PackRat/Network/KeychainService.swift +++ b/apps/swift/Sources/PackRat/Network/KeychainService.swift @@ -6,6 +6,12 @@ final class KeychainService: Sendable { private init() {} private let service = "com.andrewbierman.packrat" + private let legacyExpoService = "app" + private let legacyExpoCookieAccount = "packrat_cookie" + private let legacyExpoSessionCookieNames = [ + "better-auth.session_token", + "__Secure-better-auth.session_token", + ] private let userDefaultsPrefix = "e2e_auth_" private var usesUserDefaultsStorage: Bool { ProcessInfo.processInfo.arguments.contains("--use-userdefaults-auth") @@ -19,7 +25,16 @@ final class KeychainService: Sendable { case sessionToken = "session_token" } - var sessionToken: String? { read(.sessionToken) } + var sessionToken: String? { + if let token = read(.sessionToken) { + return token + } + guard let token = readLegacyExpoSessionToken() else { + return nil + } + saveSessionToken(token) + return token + } func saveSessionToken(_ token: String) { save(token, for: .sessionToken) @@ -30,6 +45,7 @@ final class KeychainService: Sendable { /// login screen. func clearTokens() { delete(.sessionToken) + deleteLegacyExpoCookie() } private func save(_ value: String, for key: Key) { @@ -83,4 +99,105 @@ final class KeychainService: Sendable { private func userDefaultsKey(_ key: Key) -> String { "\(userDefaultsPrefix)\(key.rawValue)" } + + private func readLegacyExpoSessionToken() -> String? { + if usesUserDefaultsStorage { + return nil + } + guard let cookieData = readRawKeychainValue( + service: legacyExpoService, + account: legacyExpoCookieAccount, + generic: legacyExpoCookieAccount + ) ?? readRawKeychainValue( + service: legacyExpoService, + account: legacyExpoCookieAccount, + generic: nil + ), + let cookieString = String(data: cookieData, encoding: .utf8), + let data = cookieString.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + + for cookieName in legacyExpoSessionCookieNames { + guard let cookie = object[cookieName] as? [String: Any], + let value = cookie["value"] as? String, + !value.isEmpty + else { + continue + } + return value + } + return nil + } + + private func readRawKeychainValue(service: String, account: String, generic: String?) -> Data? { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + if let generic { + query[kSecAttrGeneric] = generic.data(using: .utf8) + } + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { + return nil + } + return result as? Data + } + + private func saveRawKeychainValue(_ value: String, service: String, account: String, generic: String?) { + let data = Data(value.utf8) + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + ] + if let generic { + query[kSecAttrGeneric] = generic.data(using: .utf8) + } + SecItemDelete(query as CFDictionary) + var attributes = query + attributes[kSecValueData] = data + SecItemAdd(attributes as CFDictionary, nil) + } + + private func deleteLegacyExpoCookie() { + if usesUserDefaultsStorage { + return + } + let preciseQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: legacyExpoService, + kSecAttrAccount: legacyExpoCookieAccount, + kSecAttrGeneric: legacyExpoCookieAccount.data(using: .utf8) as Any, + ] + SecItemDelete(preciseQuery as CFDictionary) + + let fallbackQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: legacyExpoService, + kSecAttrAccount: legacyExpoCookieAccount, + ] + SecItemDelete(fallbackQuery as CFDictionary) + } + + #if DEBUG + func saveLegacyExpoCookieForTesting(_ value: String) { + saveRawKeychainValue( + value, + service: legacyExpoService, + account: legacyExpoCookieAccount, + generic: legacyExpoCookieAccount + ) + } + + func clearLegacyExpoCookieForTesting() { + deleteLegacyExpoCookie() + } + #endif } diff --git a/apps/swift/Sources/PackRat/Persistence/PersistenceController.swift b/apps/swift/Sources/PackRat/Persistence/PersistenceController.swift index 9edc7f3c11..d8181c7eef 100644 --- a/apps/swift/Sources/PackRat/Persistence/PersistenceController.swift +++ b/apps/swift/Sources/PackRat/Persistence/PersistenceController.swift @@ -11,6 +11,10 @@ final class PersistenceController { let schema = Schema([CachedPack.self, CachedTrip.self, ShoppingItem.self]) let config = ModelConfiguration("PackRat", schema: schema) do { + try FileManager.default.createDirectory( + at: FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0], + withIntermediateDirectories: true + ) container = try ModelContainer(for: schema, configurations: config) } catch { fatalError("SwiftData container failed: \(error)") diff --git a/apps/swift/Sources/PackRat/Services/CatalogService.swift b/apps/swift/Sources/PackRat/Services/CatalogService.swift index 2e8128e058..45ca9e7856 100644 --- a/apps/swift/Sources/PackRat/Services/CatalogService.swift +++ b/apps/swift/Sources/PackRat/Services/CatalogService.swift @@ -12,15 +12,22 @@ final class CatalogService: Sendable { "page": "\(page)", "limit": "\(limit)", ]) - // Handle both wrapped and unwrapped responses - if let wrapped = try? await api.send(endpoint, as: CatalogSearchResponse.self) { - return wrapped.items - } - return try await api.send(endpoint) + let data = try await api.sendData(endpoint) + return try decodeCatalogItems(from: data) } func semanticSearch(query: String, limit: Int = 10) async throws -> [CatalogItem] { - let endpoint = Endpoint(.get, "/api/catalog/search", query: ["q": query, "limit": "\(limit)"]) - return try await api.send(endpoint) + let endpoint = Endpoint(.get, "/api/catalog/vector-search", query: ["q": query, "limit": "\(limit)"]) + let data = try await api.sendData(endpoint) + return try decodeCatalogItems(from: data) + } + + private func decodeCatalogItems(from data: Data) throws -> [CatalogItem] { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + if let wrapped = try? decoder.decode(CatalogSearchResponse.self, from: data) { + return wrapped.items + } + return try decoder.decode([CatalogItem].self, from: data) } } diff --git a/apps/swift/Sources/PackRat/Services/WeatherService.swift b/apps/swift/Sources/PackRat/Services/WeatherService.swift index fc697cf04e..59facf52e2 100644 --- a/apps/swift/Sources/PackRat/Services/WeatherService.swift +++ b/apps/swift/Sources/PackRat/Services/WeatherService.swift @@ -1,6 +1,12 @@ import Foundation -final class WeatherService: Sendable { +protocol WeatherServicing: Sendable { + func searchLocations(query: String) async throws -> [WeatherLocation] + func getForecast(locationId: Int) async throws -> WeatherForecastResponse + func getForecast(query: String) async throws -> WeatherForecastResponse +} + +final class WeatherService: WeatherServicing { static let shared = WeatherService() private let api: APIClient @@ -15,4 +21,9 @@ final class WeatherService: Sendable { let endpoint = Endpoint(.get, "/api/weather/forecast", query: ["id": "\(locationId)"]) return try await api.send(endpoint) } + + func getForecast(query: String) async throws -> WeatherForecastResponse { + let endpoint = Endpoint(.get, "/api/weather/by-name", query: ["q": query]) + return try await api.send(endpoint) + } } diff --git a/apps/swift/Sources/PackRat/Shared/FormSheetSizing.swift b/apps/swift/Sources/PackRat/Shared/FormSheetSizing.swift index ff52128d13..4e7f35ae81 100644 --- a/apps/swift/Sources/PackRat/Shared/FormSheetSizing.swift +++ b/apps/swift/Sources/PackRat/Shared/FormSheetSizing.swift @@ -4,11 +4,13 @@ extension View { @ViewBuilder func formSheetSize(minWidth: CGFloat = 520, idealWidth: CGFloat? = nil, minHeight: CGFloat = 520, idealHeight: CGFloat? = nil) -> some View { #if os(macOS) + let macWidth = max(minWidth, 540) + let macHeight = max(minHeight, 560) self.frame( - minWidth: minWidth, - idealWidth: idealWidth ?? minWidth, - minHeight: minHeight, - idealHeight: idealHeight ?? minHeight + minWidth: macWidth, + idealWidth: idealWidth ?? macWidth, + minHeight: macHeight, + idealHeight: idealHeight ?? macHeight ) #else self @@ -17,6 +19,12 @@ extension View { @ViewBuilder func packRatFormStyle() -> some View { + #if os(macOS) + self + .formStyle(.grouped) + .controlSize(.regular) + #else self.formStyle(.grouped) + #endif } } diff --git a/apps/swift/Sources/PackRat/Shared/VisualSampleData.swift b/apps/swift/Sources/PackRat/Shared/VisualSampleData.swift index e127a6ea5c..3dd553c630 100644 --- a/apps/swift/Sources/PackRat/Shared/VisualSampleData.swift +++ b/apps/swift/Sources/PackRat/Shared/VisualSampleData.swift @@ -68,6 +68,63 @@ enum VisualSampleData { Array(Set(guides.compactMap(\.category))).sorted() } + static func seasonSuggestions(location: String) -> SeasonSuggestionsResponse { + SeasonSuggestionsResponse( + suggestions: [ + SeasonSuggestion( + name: "Shoulder Season Overnight", + description: "Balanced kit for \(location) with warmth, rain protection, and reliable camp basics.", + category: "backpacking", + tags: ["shoulder season", "overnight"], + items: [ + SeasonSuggestionItem( + name: "Rain shell", + description: "Waterproof layer for shoulder-season weather.", + weight: 210, + weightUnit: "g", + quantity: 1, + category: "clothing", + consumable: false, + worn: false, + image: nil, + notes: "Keep this accessible for changing weather.", + catalogItemId: nil + ), + SeasonSuggestionItem( + name: "Headlamp", + description: "Reliable lighting for short autumn daylight.", + weight: 85, + weightUnit: "g", + quantity: 1, + category: "lighting", + consumable: false, + worn: false, + image: nil, + notes: "Pack fresh batteries before leaving.", + catalogItemId: nil + ), + SeasonSuggestionItem( + name: "Warm layer", + description: "Insulating layer for cool evenings and exposed breaks.", + weight: 320, + weightUnit: "g", + quantity: 1, + category: "clothing", + consumable: false, + worn: true, + image: nil, + notes: "Wear or keep near the top of the pack.", + catalogItemId: nil + ), + ] + ), + ], + totalInventoryItems: 3, + location: location, + season: "fall" + ) + } + static func catalogItems(matching query: String) -> [CatalogItem] { let allItems = [ CatalogItem( diff --git a/apps/swift/Sources/PackRatWatch/PackRatWatchApp.swift b/apps/swift/Sources/PackRatWatch/PackRatWatchApp.swift index 9b3781d1e2..81f60e47a7 100644 --- a/apps/swift/Sources/PackRatWatch/PackRatWatchApp.swift +++ b/apps/swift/Sources/PackRatWatch/PackRatWatchApp.swift @@ -9,6 +9,9 @@ struct PackRatWatchApp: App { WatchRootView() .environment(connectivity) .task { + guard ProcessInfo.processInfo.environment["PACKRAT_WATCH_DISABLE_CONNECTIVITY"] != "1" else { + return + } connectivity.activate() } } @@ -20,6 +23,8 @@ private struct WatchRootView: View { var body: some View { switch ProcessInfo.processInfo.environment["PACKRAT_WATCH_SCREENSHOT_ROUTE"] { + case "dashboard": + TrailReadyView(snapshot: connectivity.snapshot, isPhoneReachable: connectivity.isPhoneReachable) case "checklist": WatchChecklistView(pack: connectivity.snapshot.pack) case "weather": @@ -74,6 +79,7 @@ private struct TrailReadyView: View { Text(tripSubtitle) .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(2) .fixedSize(horizontal: false, vertical: true) Divider() @@ -84,11 +90,7 @@ private struct TrailReadyView: View { value: "\(snapshot.pack.packedItemCount)/\(snapshot.pack.totalItemCount)", symbol: "backpack" ) - WatchMetricRow( - title: "Weather", - value: "\(snapshot.weather.temperatureText) \(snapshot.weather.conditionText)", - symbol: snapshot.weather.symbolName - ) + WatchMetricRow(title: "Weather", value: snapshot.weather.temperatureText, symbol: snapshot.weather.symbolName) } } } else { @@ -115,7 +117,7 @@ private struct TrailReadyView: View { guard let trip = snapshot.trip else { return "Quick wrist access for the next pack, weather, and trail notes." } - return [trip.name, trip.locationName, trip.dateText] + return [trip.locationName, trip.dateText] .compactMap { $0 } .joined(separator: " - ") } @@ -181,18 +183,25 @@ private struct WatchTrailReportView: View { NavigationStack { List { Section { - VStack(alignment: .leading, spacing: 10) { - Label(trail.title, systemImage: "figure.hiking") - .font(.headline) - .lineLimit(2) - if connectivity.lastDraft != nil { - Label("Draft queued", systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(.green) + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Label(trail.title, systemImage: "figure.hiking") + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + .minimumScaleFactor(0.85) + Spacer(minLength: 4) + if connectivity.lastDraft != nil { + Image(systemName: "checkmark.circle.fill") + .font(.caption) + .accessibilityLabel("Draft queued") + } } + .foregroundStyle(connectivity.lastDraft == nil ? Color.primary : Color.green) + WatchMetricRow(title: "Condition", value: trail.conditionText, symbol: "leaf") WatchMetricRow(title: "Hazards", value: "\(trail.hazardCount)", symbol: "exclamationmark.triangle") } + .padding(.vertical, 2) } Section("Condition") { @@ -241,10 +250,13 @@ private struct WatchMetricRow: View { .foregroundStyle(.tint) .frame(width: 18) Text(title) + .lineLimit(1) + .minimumScaleFactor(0.85) Spacer() Text(value) .foregroundStyle(.secondary) .lineLimit(1) + .minimumScaleFactor(0.8) } .font(.caption) } diff --git a/apps/swift/TestPlans/iOS-Sanity.xctestplan b/apps/swift/TestPlans/iOS-Sanity.xctestplan new file mode 100644 index 0000000000..6c787162a9 --- /dev/null +++ b/apps/swift/TestPlans/iOS-Sanity.xctestplan @@ -0,0 +1,36 @@ +{ + "configurations" : [ + { + "id" : "5E1A1F00-0003-4001-A000-000000000001", + "name" : "Default", + "options" : { + + } + } + ], + "defaultOptions" : { + "areLocalizationScreenshotsEnabled" : false, + "codeCoverage" : false, + "diagnosticCollectionPolicy" : "Never", + "language" : "en", + "region" : "US", + "testTimeoutsEnabled" : true, + "maximumTestExecutionTimeAllowance" : 180 + }, + "testTargets" : [ + { + "selectedTests" : [ + "AuthTests/testAuthWelcomeScreenAppears()", + "AuthTests/testContinueWithoutLoginOpensAppShell()", + "AuthTests/testSuccessfulLogin()", + "NavigationTests/testAllPrimaryTabsReachable()" + ], + "target" : { + "containerPath" : "container:PackRat.xcodeproj", + "identifier" : "PackRatUITests", + "name" : "PackRatUITests" + } + } + ], + "version" : 1 +} diff --git a/apps/swift/Tests/PackRatMacOSUITests/Info.plist b/apps/swift/Tests/PackRatMacOSUITests/Info.plist index 7482678a7f..c75250e46d 100644 --- a/apps/swift/Tests/PackRatMacOSUITests/Info.plist +++ b/apps/swift/Tests/PackRatMacOSUITests/Info.plist @@ -18,6 +18,8 @@ 1.0 CFBundleVersion 1 + E2E_API_BASE_URL + $(E2E_API_BASE_URL) PACKRAT_E2E_EMAIL $(PACKRAT_E2E_EMAIL) PACKRAT_E2E_PASSWORD @@ -26,7 +28,13 @@ $(PACKRAT_E2E_SESSION_TOKEN) PACKRAT_E2E_USER_ID $(PACKRAT_E2E_USER_ID) + PACKRAT_E2E_ALLOW_LOGIN_SEED + $(PACKRAT_E2E_ALLOW_LOGIN_SEED) PACKRAT_SCREENSHOT_DIR $(PACKRAT_SCREENSHOT_DIR) + PACKRAT_VISUAL_AUTH_MODE + $(PACKRAT_VISUAL_AUTH_MODE) + PACKRAT_VISUAL_PLATFORM + $(PACKRAT_VISUAL_PLATFORM) diff --git a/apps/swift/Tests/PackRatMacUITests/MacHomeFeatureTests.swift b/apps/swift/Tests/PackRatMacUITests/MacHomeFeatureTests.swift deleted file mode 100644 index a0d7af9060..0000000000 --- a/apps/swift/Tests/PackRatMacUITests/MacHomeFeatureTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index c8c47ca0ef..0000000000 --- a/apps/swift/Tests/PackRatMacUITests/MacNavigationTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index c67737f1b7..0000000000 --- a/apps/swift/Tests/PackRatMacUITests/MacPackTripTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index b758faffc0..0000000000 --- a/apps/swift/Tests/PackRatMacUITests/MacSecondaryFeatureTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index 26d6b21db4..0000000000 --- a/apps/swift/Tests/PackRatMacUITests/MacSmokeTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index bb8b524496..0000000000 --- a/apps/swift/Tests/PackRatMacUITests/MacUITestCase.swift +++ /dev/null @@ -1,164 +0,0 @@ -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 deleted file mode 100644 index 7953e24606..0000000000 --- a/apps/swift/Tests/PackRatMacUITests/MacWeatherTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -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/PackRatTests/DeepLinkTests.swift b/apps/swift/Tests/PackRatTests/DeepLinkTests.swift index ba935da6c9..44818714fa 100644 --- a/apps/swift/Tests/PackRatTests/DeepLinkTests.swift +++ b/apps/swift/Tests/PackRatTests/DeepLinkTests.swift @@ -51,4 +51,25 @@ struct DeepLinkTests { let url = URL(string: "com.andrewbierman.packrat://oauth-callback")! #expect(DeepLink.parse(url) == .unknown(url)) } + + @MainActor + @Test("applies parsed links to native navigation state") + func appliesLinksToNavigationState() { + let state = AppState() + + #expect(state.apply(.pack(id: "pack-1"))) + #expect(state.navItem == .packs) + #expect(state.selectedPackId == "pack-1") + + #expect(state.apply(.trip(id: "trip-1"))) + #expect(state.navItem == .trips) + #expect(state.selectedTripId == "trip-1") + + #expect(state.apply(.weather)) + #expect(state.navItem == .weather) + + let unknown = URL(string: "packrat://unknown")! + #expect(!state.apply(.unknown(unknown))) + #expect(state.navItem == .weather) + } } diff --git a/apps/swift/Tests/PackRatTests/ModelTests.swift b/apps/swift/Tests/PackRatTests/ModelTests.swift index a0ae911c95..bd755887b1 100644 --- a/apps/swift/Tests/PackRatTests/ModelTests.swift +++ b/apps/swift/Tests/PackRatTests/ModelTests.swift @@ -223,6 +223,293 @@ struct CatalogItemTests { #expect(inStock.isInStock == true) #expect(outOfStock.isInStock == false) } + + @Test("decodes Expo catalog rows with unknown weight") + func decodesNullableWeightFromSharedSchema() throws { + let json = """ + { + "id": 42, + "name": "Imported Gear", + "productUrl": "https://example.com/imported", + "sku": "SCRAPY-42", + "weight": null, + "weightUnit": null, + "description": null, + "categories": ["Shelter"], + "images": null, + "brand": "PackRat", + "model": null, + "ratingValue": null, + "color": null, + "size": null, + "price": null, + "availability": "in_stock", + "seller": null, + "reviewCount": null, + "createdAt": "2026-08-07T00:00:00.000Z", + "updatedAt": "2026-08-07T00:00:00.000Z" + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let item = try decoder.decode(CatalogItem.self, from: json) + + #expect(item.weight == nil) + #expect(item.weightUnit == nil) + #expect(item.displayWeight == "") + } + + @Test("catalog search response accepts shared API totalCount") + func catalogSearchResponseAcceptsTotalCount() throws { + let json = """ + { + "items": [], + "totalCount": 12, + "page": 1, + "limit": 20, + "totalPages": 1 + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(CatalogSearchResponse.self, from: json) + + #expect(response.items.isEmpty) + #expect(response.total == 12) + #expect(response.page == 1) + #expect(response.limit == 20) + } +} + +// MARK: - Expo/shared API compatibility + +@Suite("Expo shared API payload compatibility") +struct ExpoSharedPayloadCompatibilityTests { + private var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } + + @Test("decodes representative Expo-era user data used by the Swift update") + func decodesRepresentativeExpoUserData() throws { + let payload = """ + { + "user": { + "id": "019f7b5f-e2e0-7000-9000-packrat000001", + "email": "tester@example.com", + "name": "Taylor Hiker", + "firstName": "Taylor", + "lastName": "Hiker", + "role": "USER", + "emailVerified": true, + "avatarUrl": null, + "createdAt": "2026-08-08T00:00:00.000Z", + "updatedAt": "2026-08-08T00:00:00.000Z" + }, + "pack": { + "id": "pack-expo-1", + "userId": "019f7b5f-e2e0-7000-9000-packrat000001", + "name": "Expo Weekend Pack", + "description": "Existing mobile user pack", + "category": "backpacking", + "isPublic": false, + "image": null, + "tags": ["weekend", "qa"], + "templateId": null, + "deleted": false, + "isAIGenerated": false, + "totalWeight": 2380, + "baseWeight": 1920, + "wornWeight": 300, + "consumableWeight": 160, + "createdAt": "2026-08-08T00:00:00.000Z", + "updatedAt": "2026-08-08T00:00:00.000Z", + "items": [ + { + "id": "item-expo-1", + "packId": "pack-expo-1", + "name": "Rain Shell", + "description": null, + "weight": 210, + "weightUnit": "g", + "quantity": 1, + "category": "clothing", + "consumable": false, + "worn": true, + "image": null, + "notes": "Existing Expo item", + "catalogItemId": 7001, + "userId": "019f7b5f-e2e0-7000-9000-packrat000001", + "deleted": false, + "isAIGenerated": false, + "templateItemId": null, + "createdAt": "2026-08-08T00:00:00.000Z", + "updatedAt": "2026-08-08T00:00:00.000Z" + } + ] + }, + "trip": { + "id": "trip-expo-1", + "name": "Indian Peaks Loop", + "description": "Synced from existing mobile data", + "notes": "Watch weather before Pawnee Pass", + "location": { + "latitude": 40.083, + "longitude": -105.585, + "name": "Brainard Lake Recreation Area" + }, + "startDate": "2026-09-05T00:00:00.000Z", + "endDate": "2026-09-07T00:00:00.000Z", + "userId": "019f7b5f-e2e0-7000-9000-packrat000001", + "packId": "pack-expo-1", + "deleted": false, + "createdAt": "2026-08-08T00:00:00.000Z", + "updatedAt": "2026-08-08T00:00:00.000Z" + }, + "template": { + "id": "template-expo-1", + "userId": "019f7b5f-e2e0-7000-9000-packrat000001", + "name": "Weekend Backpacking", + "description": "Saved template", + "category": "backpacking", + "image": null, + "tags": ["saved"], + "isAppTemplate": false, + "contentSource": "user", + "createdAt": "2026-08-08T00:00:00.000Z", + "updatedAt": "2026-08-08T00:00:00.000Z", + "items": [ + { + "id": "template-item-expo-1", + "packTemplateId": "template-expo-1", + "name": "Headlamp", + "weight": 85, + "weightUnit": "g", + "quantity": 1, + "category": "lighting", + "consumable": false, + "worn": false, + "notes": null + } + ] + }, + "catalog": { + "items": [ + { + "id": 9001, + "name": "Imported Catalog Item", + "productUrl": "https://example.com/catalog/imported", + "sku": "EXPO-CATALOG-1", + "weight": null, + "weightUnit": null, + "description": "ETL row without weight metadata", + "categories": ["Shelter"], + "images": null, + "brand": "PackRat", + "model": null, + "ratingValue": null, + "color": null, + "size": null, + "price": null, + "availability": "in_stock", + "seller": null, + "reviewCount": null + } + ], + "totalCount": 1, + "page": 1, + "limit": 20 + }, + "weather": { + "location": { "id": 5582371, "name": "Boulder", "region": "Colorado", "country": "United States", "lat": 40.02, "lon": -105.27 }, + "current": { + "temp_f": 72, + "temp_c": 22.2, + "feelslike_f": 72, + "feelslike_c": 22.2, + "humidity": 35, + "wind_mph": 8, + "wind_kph": 12.9, + "condition": { "text": "Sunny", "icon": null, "code": 1000 } + }, + "forecast": { "forecastday": [] }, + "alerts": { "alert": [] } + }, + "trailReport": { + "id": "trail-expo-1", + "trailName": "Pawnee Pass", + "trailRegion": "Indian Peaks Wilderness", + "surface": "mixed", + "overallCondition": "good", + "hazards": ["afternoon storms"], + "waterCrossings": 2, + "waterCrossingDifficulty": "moderate", + "notes": "Snow lingering above treeline", + "photos": [], + "userId": "019f7b5f-e2e0-7000-9000-packrat000001", + "tripId": "trip-expo-1", + "deleted": false, + "createdAt": "2026-08-08T00:00:00.000Z", + "updatedAt": "2026-08-08T00:00:00.000Z" + }, + "season": { + "suggestions": [ + { + "name": "Windy Alpine Add-ons", + "description": "Extra warmth and weather protection", + "category": "backpacking", + "tags": ["alpine"], + "items": [ + { + "name": "Warm hat", + "description": null, + "weight": 55, + "weightUnit": "g", + "quantity": 1, + "category": "clothing", + "consumable": false, + "worn": true, + "image": null, + "notes": null, + "catalogItemId": null + } + ] + } + ], + "totalInventoryItems": 1, + "location": "Boulder, CO", + "season": "fall" + } + } + """.data(using: .utf8)! + + let snapshot = try decoder.decode(ExpoPayloadSnapshot.self, from: payload) + + #expect(snapshot.user.id == snapshot.pack.userId) + #expect(snapshot.user.displayName == "Taylor Hiker") + #expect(snapshot.pack.activeItems.count == 1) + #expect(snapshot.pack.activeItems.first?.catalogItemId == 7001) + #expect(snapshot.trip.packId == snapshot.pack.id) + #expect(snapshot.trip.location?.name == "Brainard Lake Recreation Area") + #expect(snapshot.template.itemCount == 1) + #expect(snapshot.catalog.items.first?.displayWeight == "") + #expect(snapshot.catalog.total == 1) + #expect(snapshot.weather.current?.condition?.sfSymbol == "sun.max") + #expect(snapshot.trailReport.hazards == ["afternoon storms"]) + #expect(snapshot.season.suggestions.first?.items?.first?.name == "Warm hat") + } + + private struct ExpoPayloadSnapshot: Decodable { + let user: User + let pack: Pack + let trip: Trip + let template: PackTemplate + let catalog: CatalogSearchResponse + let weather: WeatherForecastResponse + let trailReport: TrailConditionReport + let season: SeasonSuggestionsResponse + } } // MARK: - Enum decoding diff --git a/apps/swift/Tests/PackRatTests/NetworkTests.swift b/apps/swift/Tests/PackRatTests/NetworkTests.swift index 8130b0fabf..f3cd4132c0 100644 --- a/apps/swift/Tests/PackRatTests/NetworkTests.swift +++ b/apps/swift/Tests/PackRatTests/NetworkTests.swift @@ -37,6 +37,49 @@ struct KeychainServiceTests { #expect(keychain.sessionToken == "second") keychain.clearTokens() } + + @Test("reads and migrates Expo Better Auth session cookie") + func readsAndMigratesExpoBetterAuthCookie() { + keychain.saveLegacyExpoCookieForTesting(""" + {"better-auth.session_token":{"value":"legacy-session-token"}} + """) + + #expect(keychain.sessionToken == "legacy-session-token") + + keychain.clearLegacyExpoCookieForTesting() + #expect(keychain.sessionToken == "legacy-session-token") + keychain.clearTokens() + } + + @Test("reads Expo secure Better Auth session cookie") + func readsExpoSecureBetterAuthCookie() { + keychain.saveLegacyExpoCookieForTesting(""" + {"__Secure-better-auth.session_token":{"value":"secure-legacy-session-token"}} + """) + + #expect(keychain.sessionToken == "secure-legacy-session-token") + keychain.clearTokens() + } + + @Test("ignores invalid Expo cookie payload") + func ignoresInvalidExpoCookiePayload() { + keychain.saveLegacyExpoCookieForTesting(""" + {"better-auth.session_token":{"value":""}} + """) + + #expect(keychain.sessionToken == nil) + keychain.clearTokens() + } + + @Test("clearTokens removes Expo Better Auth cookie") + func clearTokensRemovesExpoCookie() { + keychain.saveLegacyExpoCookieForTesting(""" + {"better-auth.session_token":{"value":"legacy-session-token"}} + """) + + keychain.clearTokens() + #expect(keychain.sessionToken == nil) + } } // MARK: - APIEndpoint / Endpoint builder diff --git a/apps/swift/Tests/PackRatTests/OfflineAITests.swift b/apps/swift/Tests/PackRatTests/OfflineAITests.swift index b6ceee9bdb..95bef0e4dc 100644 --- a/apps/swift/Tests/PackRatTests/OfflineAITests.swift +++ b/apps/swift/Tests/PackRatTests/OfflineAITests.swift @@ -1,4 +1,5 @@ import Foundation +import Defaults import Testing @testable import PackRat @@ -258,3 +259,20 @@ struct OfflineAIViewModelTests { #expect(vm.state == .idle) } } + +// MARK: - LocalLLMProviderFactory + +@Suite("LocalLLMProviderFactory") +@MainActor +struct LocalLLMProviderFactoryTests { + @Test("uses mock provider even when stale MLX debug preference is enabled") + func staleMLXPreferenceUsesMockProvider() { + let previousValue = Defaults[.useRealLocalLLM] + Defaults[.useRealLocalLLM] = true + defer { Defaults[.useRealLocalLLM] = previousValue } + + let provider = LocalLLMProviderFactory.makeProvider() + + #expect(provider is MockLocalLLMProvider) + } +} diff --git a/apps/swift/Tests/PackRatTests/ViewModelTests.swift b/apps/swift/Tests/PackRatTests/ViewModelTests.swift index f0b2c7c110..784a740258 100644 --- a/apps/swift/Tests/PackRatTests/ViewModelTests.swift +++ b/apps/swift/Tests/PackRatTests/ViewModelTests.swift @@ -25,6 +25,34 @@ private func mockTrip(id: String = "t1", name: String = "Test Trip", startDate: deleted: false, createdAt: nil, updatedAt: nil) } +@Suite("WeatherTemperatureDisplay") +struct WeatherTemperatureDisplayTests { + @Test("uses Celsius value when Celsius is preferred") + func usesPreferredCelsiusValue() { + #expect(WeatherTemperatureDisplay.format(celsius: 20, fahrenheit: 99, unit: .celsius) == "20°C") + } + + @Test("uses Fahrenheit value when Fahrenheit is preferred") + func usesPreferredFahrenheitValue() { + #expect(WeatherTemperatureDisplay.format(celsius: 99, fahrenheit: 68, unit: .fahrenheit) == "68°F") + } + + @Test("converts Fahrenheit fallback to Celsius") + func convertsFahrenheitFallback() { + #expect(WeatherTemperatureDisplay.format(celsius: nil, fahrenheit: 68, unit: .celsius) == "20°C") + } + + @Test("converts Celsius fallback to Fahrenheit") + func convertsCelsiusFallback() { + #expect(WeatherTemperatureDisplay.format(celsius: 20, fahrenheit: nil, unit: .fahrenheit) == "68°F") + } + + @Test("shows placeholder only when both values are unavailable") + func showsMissingPlaceholder() { + #expect(WeatherTemperatureDisplay.format(celsius: nil, fahrenheit: nil, unit: .celsius) == "—") + } +} + // MARK: - PacksViewModel @Suite("PacksViewModel") @@ -124,7 +152,7 @@ struct TripsViewModelTests { struct WeatherViewModelTests { @Test("onSearchTextChanged clears results when empty") @MainActor func clearsResultsWhenEmpty() { - let vm = WeatherViewModel() + let vm = WeatherViewModel(loadPersistedState: false) vm.searchResults = [WeatherLocation(id: 1, name: "Denver", region: nil, country: nil, lat: nil, lon: nil)] vm.searchText = "" vm.onSearchTextChanged() @@ -133,11 +161,90 @@ struct WeatherViewModelTests { @Test("searchText below 2 chars does not search") @MainActor func shortQuerySkipsSearch() { - let vm = WeatherViewModel() + let vm = WeatherViewModel(loadPersistedState: false) vm.searchText = "D" vm.onSearchTextChanged() #expect(vm.searchResults.isEmpty) } + + @Test("selectLocation falls back to by-name forecast when id lookup fails") + @MainActor func forecastFallsBackToByName() async { + let service = FallbackWeatherService() + let vm = WeatherViewModel(service: service, loadPersistedState: false) + let denver = WeatherLocation( + id: 5419384, + name: "Denver", + region: "Colorado", + country: "United States", + lat: 39.74, + lon: -104.98 + ) + + await vm.selectLocation(denver) + + #expect(vm.forecast?.location?.name == "Denver") + #expect(vm.forecastError == nil) + #expect(await service.idForecastRequests == 1) + #expect(await service.nameForecastQueries == ["Denver, Colorado"]) + } +} + +private actor FallbackWeatherService: WeatherServicing { + private(set) var idForecastRequests = 0 + private(set) var nameForecastQueries: [String] = [] + + func searchLocations(query: String) async throws -> [WeatherLocation] { + [ + WeatherLocation( + id: 5419384, + name: "Denver", + region: "Colorado", + country: "United States", + lat: 39.74, + lon: -104.98 + ), + ] + } + + func getForecast(locationId: Int) async throws -> WeatherForecastResponse { + idForecastRequests += 1 + throw PackRatError.httpError(statusCode: 500, message: "WeatherAPI HTTP 500") + } + + func getForecast(query: String) async throws -> WeatherForecastResponse { + nameForecastQueries.append(query) + return WeatherForecastResponse( + location: WeatherResponseLocation( + id: 5419384, + name: "Denver", + region: "Colorado", + country: "United States", + lat: 39.74, + lon: -104.98, + localtime: nil, + localtimeEpoch: nil, + tzId: nil + ), + current: WeatherCurrent( + tempC: 22, + tempF: 72, + feelslikeC: 22, + feelslikeF: 72, + humidity: 32, + windMph: 7, + windKph: 11, + windDir: "WSW", + condition: WeatherCondition(text: "Partly cloudy", icon: nil, code: 1003), + uv: 5, + visMiles: 10, + precipIn: 0, + cloud: 25, + isDay: 1 + ), + forecast: WeatherForecast(forecastday: []), + alerts: WeatherAlertsWrapper(alert: []) + ) + } } // MARK: - CatalogViewModel diff --git a/apps/swift/Tests/PackRatUITests/AppUITestCase.swift b/apps/swift/Tests/PackRatUITests/AppUITestCase.swift index 626f17c718..05a09708d8 100644 --- a/apps/swift/Tests/PackRatUITests/AppUITestCase.swift +++ b/apps/swift/Tests/PackRatUITests/AppUITestCase.swift @@ -36,19 +36,29 @@ class AppUITestCase: XCTestCase { // bearer token accepted by the worker, avoiding brittle UI sign-in // while still exercising authenticated API routes. app.launchArguments.append("--reset-auth") + // Feature suites are not login tests. Local API runs can opt into + // deterministic seeded auth; deployed API runs must use real login so + // TestFlight-style failures are visible in E2E and screenshots. + if e2eLoginSeedAllowed { + app.launchArguments.append("--allow-e2e-login-seed") + app.launchEnvironment["PACKRAT_E2E_ALLOW_LOGIN_SEED"] = "1" + } app.launchArguments.append(contentsOf: additionalLaunchArguments) if let apiBaseURL = ProcessInfo.processInfo.environment["E2E_API_BASE_URL"], !apiBaseURL.isEmpty { app.launchEnvironment["E2E_API_BASE_URL"] = apiBaseURL } + injectE2EAuthEnvironment() let bundle = Bundle(for: AppUITestCase.self) - let seededAuthToken = - (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_SESSION_TOKEN") as? String) - ?? ProcessInfo.processInfo.environment["PACKRAT_E2E_SESSION_TOKEN"] + let seededAuthEmail = + ProcessInfo.processInfo.environment["PACKRAT_E2E_EMAIL"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_EMAIL") as? String) ?? "" - if !seededAuthToken.isEmpty { + if e2eLoginSeedAllowed && !seededAuthEmail.isEmpty { let runnerEnvironment = ProcessInfo.processInfo.environment app.launchArguments.append("--seed-e2e-auth") - app.launchEnvironment["PACKRAT_E2E_SESSION_TOKEN"] = seededAuthToken + app.launchEnvironment["PACKRAT_E2E_SESSION_TOKEN"] = runnerEnvironment["PACKRAT_E2E_SESSION_TOKEN"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_SESSION_TOKEN") as? String) + ?? "" app.launchEnvironment["PACKRAT_E2E_EMAIL"] = runnerEnvironment["PACKRAT_E2E_EMAIL"] ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_EMAIL") as? String) ?? "" @@ -65,6 +75,14 @@ class AppUITestCase: XCTestCase { // MARK: - Login helper + var e2eLoginSeedAllowed: Bool { + let bundle = Bundle(for: AppUITestCase.self) + let value = ProcessInfo.processInfo.environment["PACKRAT_E2E_ALLOW_LOGIN_SEED"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_ALLOW_LOGIN_SEED") as? String) + ?? "" + return value == "1" || value.caseInsensitiveCompare("true") == .orderedSame + } + /// Best-effort cross-platform "is the user logged in?" detector. /// /// iOS: tab bar is the unmistakable signal. macOS uses a NavigationSplitView @@ -116,23 +134,82 @@ class AppUITestCase: XCTestCase { submitLoginForm() + let loggedIn = waitForLoggedIn(timeout: 20) XCTAssertTrue( - waitForLoggedIn(timeout: 20), - "Logged-in landmark must appear after login — check credentials or network" + loggedIn, + "Logged-in landmark must appear after login — \(visibleLoginFailureMessage())" ) } + private func injectE2EAuthEnvironment() { + let bundle = Bundle(for: AppUITestCase.self) + let runnerEnvironment = ProcessInfo.processInfo.environment + app.launchEnvironment["PACKRAT_E2E_EMAIL"] = runnerEnvironment["PACKRAT_E2E_EMAIL"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_EMAIL") as? String) + ?? "" + app.launchEnvironment["PACKRAT_E2E_PASSWORD"] = runnerEnvironment["PACKRAT_E2E_PASSWORD"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_PASSWORD") as? String) + ?? "" + app.launchEnvironment["PACKRAT_E2E_SESSION_TOKEN"] = runnerEnvironment["PACKRAT_E2E_SESSION_TOKEN"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_SESSION_TOKEN") as? String) + ?? "" + app.launchEnvironment["PACKRAT_E2E_USER_ID"] = runnerEnvironment["PACKRAT_E2E_USER_ID"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_USER_ID") as? String) + ?? "" + if e2eLoginSeedAllowed { + app.launchEnvironment["PACKRAT_E2E_ALLOW_LOGIN_SEED"] = "1" + } + } + func submitLoginForm() { + let submitButton = app.buttons["login_submit"] + XCTAssertTrue(submitButton.waitForExistence(timeout: 5), "Login submit button must be visible") + XCTAssertTrue(submitButton.isEnabled, "Login submit button must be enabled after credentials are filled") #if os(macOS) // macOS can show a password/autofill popover over the submit button // after typing into SecureField. Escape dismisses it before tapping. app.typeText("\u{1b}") - app.buttons["login_submit"].tap() + submitButton.tap() #else - app.buttons["login_submit"].tap() + if submitButton.isHittable { + submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + Thread.sleep(forTimeInterval: 0.25) + if submitButton.exists, submitButton.isHittable, submitButton.isEnabled { + submitButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + } else { + let goButton = app.keyboards.buttons["Go"] + let returnButton = app.keyboards.buttons["Return"] + if goButton.waitForExistence(timeout: 1) { + goButton.tap() + } else if returnButton.waitForExistence(timeout: 1) { + returnButton.tap() + } else { + app.swipeUp() + if submitButton.waitForExistence(timeout: 2), submitButton.isHittable { + submitButton.tap() + } else { + app.typeText("\n") + } + } + } #endif } + func visibleLoginFailureMessage() -> String { + let loginError = app.staticTexts["login_error"] + if loginError.exists, !loginError.label.isEmpty { + return "visible login error: \(loginError.label)" + } + + let inlineError = app.staticTexts["inline_error"] + if inlineError.exists, !inlineError.label.isEmpty { + return "visible inline error: \(inlineError.label)" + } + + return "no visible login error" + } + /// Cross-platform "is the user logged in NOW?" wait with an explicit timeout. /// Distinct from `isLoggedIn` (which has a short fixed wait) so login flow /// can wait longer than the warm-cache short-circuit check. @@ -141,8 +218,8 @@ class AppUITestCase: XCTestCase { #if os(iOS) return app.tabBars.firstMatch.waitForExistence(timeout: timeout) #elseif os(macOS) - return app.otherElements["app_navigation"].waitForExistence(timeout: timeout) - || app.outlines["app_sidebar"].waitForExistence(timeout: 1) + return app.outlines["app_sidebar"].waitForExistence(timeout: timeout) + || app.otherElements["app_navigation"].waitForExistence(timeout: 1) || app.staticTexts["Home"].waitForExistence(timeout: 1) || app.outlines.firstMatch.waitForExistence(timeout: 1) #else @@ -232,8 +309,8 @@ class AppUITestCase: XCTestCase { } let direct = app.tabBars.buttons[label] - if direct.exists { - direct.tap() + if direct.waitForExistence(timeout: 5) { + tapTabBarButton(direct) return } @@ -272,11 +349,7 @@ class AppUITestCase: XCTestCase { } } - XCTAssertTrue( - direct.waitForExistence(timeout: 5), - "Primary tab '\(label)' not found in tab bar" - ) - direct.tap() + XCTFail("Primary tab '\(label)' not found in tab bar") } func goToHomeAction(_ title: String) { @@ -308,6 +381,19 @@ class AppUITestCase: XCTestCase { let tabBarTop = app.tabBars.firstMatch.exists ? app.tabBars.firstMatch.frame.minY : app.frame.maxY return element.frame.minY >= 0 && element.frame.maxY <= tabBarTop - 12 } + + private func tapTabBarButton(_ element: XCUIElement) { + let frame = element.frame + guard frame.width > 0, frame.height > 0 else { + element.tap() + return + } + let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) + .withOffset(CGVector(dx: frame.midX, dy: frame.midY)) + coordinate.tap() + Thread.sleep(forTimeInterval: 0.35) + coordinate.tap() + } #endif // MARK: - Wait helpers @@ -326,6 +412,30 @@ class AppUITestCase: XCTestCase { XCTAssertEqual(result, .completed, "\(element.description) should have disappeared") } + func waitForStaticText(containing text: String, timeout: TimeInterval = 10) -> Bool { + let predicate = NSPredicate { [weak self] _, _ in + guard let self else { return false } + return self.app.staticTexts.allElementsBoundByIndex.contains { element in + element.exists && element.label.localizedCaseInsensitiveContains(text) + } + } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: nil) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + + func waitForElement(identifier: String, containing text: String, timeout: TimeInterval = 10) -> Bool { + let predicate = NSPredicate { [weak self] _, _ in + guard let self else { return false } + return self.app.descendants(matching: .any).matching(identifier: identifier) + .allElementsBoundByIndex + .contains { element in + element.exists && element.label.localizedCaseInsensitiveContains(text) + } + } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: nil) + return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed + } + /// Attaches a screenshot + accessibility tree dump on failure for triage. override func tearDown() { if let testRun, testRun.totalFailureCount > 0 { diff --git a/apps/swift/Tests/PackRatUITests/AuthTests.swift b/apps/swift/Tests/PackRatUITests/AuthTests.swift index 2fe69979dc..6da99ac89d 100644 --- a/apps/swift/Tests/PackRatUITests/AuthTests.swift +++ b/apps/swift/Tests/PackRatUITests/AuthTests.swift @@ -8,7 +8,10 @@ final class AuthTests: AppUITestCase { app.launchArguments.append("--use-userdefaults-auth") // Force logged-out state so the login screen is reachable. app.launchArguments.append("--reset-auth") - app.launchArguments.append("--allow-e2e-login-seed") + if e2eLoginSeedAllowed { + app.launchArguments.append("--allow-e2e-login-seed") + app.launchEnvironment["PACKRAT_E2E_ALLOW_LOGIN_SEED"] = "1" + } if let apiBaseURL = ProcessInfo.processInfo.environment["E2E_API_BASE_URL"], !apiBaseURL.isEmpty { app.launchEnvironment["E2E_API_BASE_URL"] = apiBaseURL } @@ -73,6 +76,20 @@ final class AuthTests: AppUITestCase { XCTAssertTrue(app.buttons["Sign In or Create Account"].exists) XCTAssertFalse(app.buttons["Try Again"].exists) XCTAssertFalse(app.staticTexts["Connection Needed"].exists) + + app.buttons["Done"].tapIfExists() + goToHomeAction("Catalog") + XCTAssertTrue(app.staticTexts["Catalog Requires an Account"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["Sign In or Create Account"].exists) + XCTAssertFalse(app.buttons["Try Again"].exists) + XCTAssertFalse(app.staticTexts["Connection Needed"].exists) + + app.buttons["Done"].tapIfExists() + goToHomeAction("Weather") + XCTAssertTrue(app.staticTexts["Weather Requires an Account"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["Sign In or Create Account"].exists) + XCTAssertFalse(app.buttons["Try Again"].exists) + XCTAssertFalse(app.staticTexts["Connection Needed"].exists) } func testGuestSeesNativeSignInStateForAITools() { @@ -214,7 +231,11 @@ final class AuthTests: AppUITestCase { submitLoginForm() - XCTAssertTrue(waitForLoggedIn(timeout: 20), "Logged-in landmark must appear after successful login") + let loggedIn = waitForLoggedIn(timeout: 20) + XCTAssertTrue( + loggedIn, + "Logged-in landmark must appear after successful login — \(visibleLoginFailureMessage())" + ) XCTAssertFalse(app.textFields["login_email"].exists, "Login form should be dismissed") } @@ -235,6 +256,9 @@ final class AuthTests: AppUITestCase { (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_SESSION_TOKEN") as? String) ?? "" app.launchEnvironment["PACKRAT_E2E_USER_ID"] = (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_USER_ID") as? String) ?? "" + if e2eLoginSeedAllowed { + app.launchEnvironment["PACKRAT_E2E_ALLOW_LOGIN_SEED"] = "1" + } } #if os(iOS) diff --git a/apps/swift/Tests/PackRatUITests/ChatMacOSTests.swift b/apps/swift/Tests/PackRatUITests/ChatMacOSTests.swift index a769763391..9ef5f6cafe 100644 --- a/apps/swift/Tests/PackRatUITests/ChatMacOSTests.swift +++ b/apps/swift/Tests/PackRatUITests/ChatMacOSTests.swift @@ -55,7 +55,7 @@ final class ChatMacOSTests: AppUITestCase { // User bubble should show "Hi" XCTAssertTrue( - app.staticTexts["Hi"].waitForExistence(timeout: 5), + waitForElement(identifier: "chat_message_user", containing: "Hi", timeout: 5), "User message bubble must appear after sending" ) @@ -65,6 +65,11 @@ final class ChatMacOSTests: AppUITestCase { } let exp = XCTNSPredicateExpectation(predicate: inputCleared, object: nil) _ = XCTWaiter.wait(for: [exp], timeout: 8) + + XCTAssertTrue( + waitForElement(identifier: "chat_message_assistant", containing: "three essential items", timeout: 15), + "Assistant response must stream back deterministic E2E content" + ) } func testClearChatHistoryButton() { diff --git a/apps/swift/Tests/PackRatUITests/ChatTests.swift b/apps/swift/Tests/PackRatUITests/ChatTests.swift index 725e026806..e2644e2626 100644 --- a/apps/swift/Tests/PackRatUITests/ChatTests.swift +++ b/apps/swift/Tests/PackRatUITests/ChatTests.swift @@ -57,7 +57,7 @@ final class ChatTests: AppUITestCase { // User message bubble should show "Hi" XCTAssertTrue( - app.staticTexts["Hi"].waitForExistence(timeout: 5), + waitForElement(identifier: "chat_message_user", containing: "Hi", timeout: 5), "User message bubble must appear after sending" ) @@ -68,6 +68,11 @@ final class ChatTests: AppUITestCase { } let exp = XCTNSPredicateExpectation(predicate: inputCleared, object: nil) _ = XCTWaiter.wait(for: [exp], timeout: 8) + + XCTAssertTrue( + waitForElement(identifier: "chat_message_assistant", containing: "three essential items", timeout: 15), + "Assistant response must stream back deterministic E2E content" + ) } func testClearChatHistoryButton() { diff --git a/apps/swift/Tests/PackRatUITests/HomeTileTests.swift b/apps/swift/Tests/PackRatUITests/HomeTileTests.swift index abeddb2662..fc275257cf 100644 --- a/apps/swift/Tests/PackRatUITests/HomeTileTests.swift +++ b/apps/swift/Tests/PackRatUITests/HomeTileTests.swift @@ -6,36 +6,53 @@ final class HomeTileTests: AppUITestCase { 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") + private let primaryNavigationTiles: [Tile] = [ + Tile(id: "home_action_mypacks", destinationTitle: "Packs"), + Tile(id: "home_action_trips", destinationTitle: "Trips"), + Tile(id: "home_action_weather", destinationTitle: "Weather"), + Tile(id: "home_action_aiassistant", destinationTitle: "AI Assistant"), ] - func testEveryHomeNavigationTileOpensDestination() { - for tile in navigationTiles { - goToTab("Home") - tapHomeTile(tile.id) + private let planningNavigationTiles: [Tile] = [ + Tile(id: "home_action_aipacks", destinationTitle: "AI Packs"), + Tile(id: "home_action_gearinventory", destinationTitle: "Gear Inventory"), + Tile(id: "home_action_packtemplates", destinationTitle: "Pack Templates"), + ] - guard let destinationTitle = tile.destinationTitle else { continue } - XCTAssertTrue( - app.navigationBars[destinationTitle].waitForExistence(timeout: 8), - "\(tile.id) must open \(destinationTitle)" - ) + private var exploreNavigationTiles: [Tile] { + var tiles: [Tile] = [ + Tile(id: "home_action_guides", destinationTitle: "Guides"), + Tile(id: "home_action_catalog", destinationTitle: "Gear Catalog"), + ] + + if UITestFeatureFlags.enableFeed { + tiles.append(Tile(id: "home_action_communityfeed", destinationTitle: "Feed")) + } + if UITestFeatureFlags.enableTrailConditions { + tiles.append(Tile(id: "home_action_trailconditions", destinationTitle: "Trail Conditions")) + } + if UITestFeatureFlags.enableWildlifeIdentification { + tiles.append(Tile(id: "home_action_wildlifeid", destinationTitle: "Wildlife")) } + + return tiles + } + + func testPrimaryHomeNavigationTilesOpenDestinations() { + assertHomeTilesOpenDestinations(primaryNavigationTiles) + } + + func testPlanningHomeNavigationTilesOpenDestinations() { + assertHomeTilesOpenDestinations(planningNavigationTiles) + } + + func testExploreHomeNavigationTilesOpenDestinations() { + assertHomeTilesOpenDestinations(exploreNavigationTiles) } func testSeasonSuggestionsTileOpensAndDismissesSheet() { - goToTab("Home") - tapHomeTile("home_tile_season_suggestions") + goHome() + tapHomeTile("home_action_seasonsuggestions") XCTAssertTrue( app.staticTexts["AI-Powered Packing Tips"].waitForExistence(timeout: 5) @@ -45,9 +62,13 @@ final class HomeTileTests: AppUITestCase { app.buttons["Done"].tapIfExists() } - func testShoppingListTileSupportsAddToggleClearAndDone() { - goToTab("Home") - tapHomeTile("home_tile_shopping_list") + func testShoppingListTileSupportsAddToggleClearAndDone() throws { + guard UITestFeatureFlags.enableShoppingList else { + throw XCTSkip("Shopping List is disabled by feature flags") + } + + goHome() + tapHomeTile("home_action_shoppinglist") XCTAssertTrue( app.navigationBars.matching(NSPredicate(format: "identifier BEGINSWITH 'Shopping List'")).firstMatch @@ -86,7 +107,32 @@ final class HomeTileTests: AppUITestCase { ) waitFor(app.buttons["shopping_done"], timeout: 5).tap() - XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 5)) + XCTAssertTrue(destinationExists("Home", timeout: 5)) + } + + private func goHome() { + #if os(macOS) + goToSidebar("Home") + #else + goToTab("Home") + #endif + } + + private func destinationExists(_ title: String, timeout: TimeInterval) -> Bool { + #if os(macOS) + let staticText = app.staticTexts[title] + let navigationBar = app.navigationBars[title] + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if staticText.exists || navigationBar.exists { + return true + } + Thread.sleep(forTimeInterval: 0.1) + } + return staticText.exists || navigationBar.exists + #else + return app.navigationBars[title].waitForExistence(timeout: timeout) + #endif } private func tapHomeTile(_ id: String) { @@ -100,6 +146,19 @@ final class HomeTileTests: AppUITestCase { waitFor(tile, timeout: 5, message: "\(id) must be visible on Home").tap() } + private func assertHomeTilesOpenDestinations(_ tiles: [Tile]) { + for tile in tiles { + goHome() + tapHomeTile(tile.id) + + guard let destinationTitle = tile.destinationTitle else { continue } + XCTAssertTrue( + destinationExists(destinationTitle, timeout: 8), + "\(tile.id) must open \(destinationTitle)" + ) + } + } + private func openOverflowMenu() { let overflow = app.buttons["OverflowBarButtonItem"] if overflow.waitForExistence(timeout: 2) { diff --git a/apps/swift/Tests/PackRatUITests/Info.plist b/apps/swift/Tests/PackRatUITests/Info.plist index 7482678a7f..c75250e46d 100644 --- a/apps/swift/Tests/PackRatUITests/Info.plist +++ b/apps/swift/Tests/PackRatUITests/Info.plist @@ -18,6 +18,8 @@ 1.0 CFBundleVersion 1 + E2E_API_BASE_URL + $(E2E_API_BASE_URL) PACKRAT_E2E_EMAIL $(PACKRAT_E2E_EMAIL) PACKRAT_E2E_PASSWORD @@ -26,7 +28,13 @@ $(PACKRAT_E2E_SESSION_TOKEN) PACKRAT_E2E_USER_ID $(PACKRAT_E2E_USER_ID) + PACKRAT_E2E_ALLOW_LOGIN_SEED + $(PACKRAT_E2E_ALLOW_LOGIN_SEED) PACKRAT_SCREENSHOT_DIR $(PACKRAT_SCREENSHOT_DIR) + PACKRAT_VISUAL_AUTH_MODE + $(PACKRAT_VISUAL_AUTH_MODE) + PACKRAT_VISUAL_PLATFORM + $(PACKRAT_VISUAL_PLATFORM) diff --git a/apps/swift/Tests/PackRatUITests/MoreTabsTests.swift b/apps/swift/Tests/PackRatUITests/MoreTabsTests.swift index b126c71cb0..6e2913d71b 100644 --- a/apps/swift/Tests/PackRatUITests/MoreTabsTests.swift +++ b/apps/swift/Tests/PackRatUITests/MoreTabsTests.swift @@ -78,6 +78,25 @@ final class MoreTabsTests: AppUITestCase { ) } + func testHomeAssistantActionProvidesBackNavigationWithoutChangingDirectTabHistory() { + goToHomeAction("AI Assistant") + XCTAssertTrue(app.navigationBars["AI Assistant"].waitForExistence(timeout: 8)) + XCTAssertTrue( + app.navigationBars["AI Assistant"].buttons["Home"].waitForExistence(timeout: 5), + "Assistant opened from Home should offer native back navigation" + ) + + app.navigationBars["AI Assistant"].buttons["Home"].tap() + XCTAssertTrue(app.navigationBars["Home"].waitForExistence(timeout: 8)) + + goToTab("Assistant") + XCTAssertTrue(app.navigationBars["AI Assistant"].waitForExistence(timeout: 8)) + XCTAssertFalse( + app.navigationBars["AI Assistant"].buttons["Home"].exists, + "The primary Assistant tab should remain a root destination" + ) + } + // MARK: - Guides func testGuidesTabReachable() { @@ -98,6 +117,22 @@ final class MoreTabsTests: AppUITestCase { ) } + func testGearInventorySortMenuExposesOptionsAndUpdatesSelectedValue() { + goToHomeAction("Gear Inventory") + XCTAssertTrue(app.navigationBars["Gear Inventory"].waitForExistence(timeout: 8)) + + let sortMenu = app.buttons["gear_inventory_sort"] + XCTAssertTrue(sortMenu.waitForExistence(timeout: 8)) + XCTAssertEqual(sortMenu.value as? String, "Name") + + sortMenu.tap() + let weight = app.buttons["Weight"] + XCTAssertTrue(weight.waitForExistence(timeout: 5)) + weight.tap() + + XCTAssertEqual(sortMenu.value as? String, "Weight") + } + // MARK: - Wildlife func testDisabledWildlifeActionIsHidden() { diff --git a/apps/swift/Tests/PackRatUITests/NavigationTests.swift b/apps/swift/Tests/PackRatUITests/NavigationTests.swift index f5f3e99400..becb7a10b7 100644 --- a/apps/swift/Tests/PackRatUITests/NavigationTests.swift +++ b/apps/swift/Tests/PackRatUITests/NavigationTests.swift @@ -5,6 +5,10 @@ import XCTest /// macOS uses a different navigation idiom (NavigationSplitView sidebar); the /// equivalent suite lives in `NavigationMacOSTests` and is gated separately. final class NavigationTests: AppUITestCase { + override var executionTimeAllowance: TimeInterval { + get { 3 * 60 } + set { _ = newValue } + } // Each entry: (tab bar label, expected navigation title or landmark text) private let tabs: [(tab: String, landmark: String)] = [ diff --git a/apps/swift/Tests/PackRatUITests/PackMacOSTests.swift b/apps/swift/Tests/PackRatUITests/PackMacOSTests.swift index a8391d6104..9631e043ec 100644 --- a/apps/swift/Tests/PackRatUITests/PackMacOSTests.swift +++ b/apps/swift/Tests/PackRatUITests/PackMacOSTests.swift @@ -184,7 +184,7 @@ final class PackMacOSTests: AppUITestCase { private func createPack(named name: String) { goToSidebar("Packs") - waitFor(app.buttons["New Pack"]).click() + waitFor(app.buttons["packs_new_pack_button"].firstMatch).click() let nameField = app.textFields["pack_name"] waitFor(nameField) diff --git a/apps/swift/Tests/PackRatUITests/PackSubFlowMacOSTests.swift b/apps/swift/Tests/PackRatUITests/PackSubFlowMacOSTests.swift index 48b8a675b3..66aaf9d99a 100644 --- a/apps/swift/Tests/PackRatUITests/PackSubFlowMacOSTests.swift +++ b/apps/swift/Tests/PackRatUITests/PackSubFlowMacOSTests.swift @@ -109,7 +109,7 @@ final class PackSubFlowMacOSTests: AppUITestCase { private func createPack(named name: String) { goToSidebar("Packs") - waitFor(app.buttons["New Pack"]).click() + waitFor(app.buttons["packs_new_pack_button"].firstMatch).click() let nameField = app.textFields["pack_name"] waitFor(nameField) nameField.click() diff --git a/apps/swift/Tests/PackRatUITests/PackTemplateTests.swift b/apps/swift/Tests/PackRatUITests/PackTemplateTests.swift index ed8a9a5854..f3d63dbb7c 100644 --- a/apps/swift/Tests/PackRatUITests/PackTemplateTests.swift +++ b/apps/swift/Tests/PackRatUITests/PackTemplateTests.swift @@ -78,12 +78,12 @@ final class PackTemplateTests: AppUITestCase { goToTab("Templates") waitFor(app.buttons["New Template"]).tap() - // Category picker should be visible + selectTemplateCategory("Backpacking") XCTAssertTrue( - app.buttons.matching(NSPredicate(format: "label CONTAINS 'Category'")).firstMatch + app.buttons.matching(NSPredicate(format: "label CONTAINS 'Backpacking'")).firstMatch .waitForExistence(timeout: 5) - || app.staticTexts["Category"].waitForExistence(timeout: 2), - "Category picker must be visible in template form" + || app.staticTexts["Backpacking"].waitForExistence(timeout: 2), + "Selected template category must be reflected in the form" ) app.buttons["Cancel"].tap() } @@ -120,6 +120,22 @@ final class PackTemplateTests: AppUITestCase { waitFor(row, timeout: 5) } + private func selectTemplateCategory(_ category: String) { + let picker = app.buttons["template_category"].firstMatch + let fallbackPicker = app.buttons.matching(NSPredicate(format: "label CONTAINS 'Category'")).firstMatch + let targetPicker = picker.waitForExistence(timeout: 2) ? picker : fallbackPicker + waitFor(targetPicker, timeout: 5, message: "Template category picker must be visible") + targetPicker.tap() + + let option = app.buttons[category].firstMatch + let optionText = app.staticTexts[category].firstMatch + if option.waitForExistence(timeout: 5) { + option.tap() + } else { + waitFor(optionText, timeout: 5, message: "Template category option '\(category)' must be visible").tap() + } + } + private func cleanupTemplate(named name: String) { // Tab navigation may be impossible if the search field is focused or // a sheet is open; wrap so cleanup never crashes the test report. diff --git a/apps/swift/Tests/PackRatUITests/PackTests.swift b/apps/swift/Tests/PackRatUITests/PackTests.swift index a036844390..772fc0411b 100644 --- a/apps/swift/Tests/PackRatUITests/PackTests.swift +++ b/apps/swift/Tests/PackRatUITests/PackTests.swift @@ -32,21 +32,19 @@ final class PackTests: AppUITestCase { } func testCreatePackWithCategory() throws { - // The createPack helper already picks Hiking as the category. - let packName = uniqueName("E2E Hiking Pack") + let packName = uniqueName("E2E Backpacking Pack") createdPackName = packName - createPack(named: packName) + createPack(named: packName, category: "Backpacking") XCTAssertTrue( app.staticTexts[packName].waitForExistence(timeout: 5), "Pack with category must appear in list" ) - // Hiking badge should be visible on the row XCTAssertTrue( - app.staticTexts["Hiking"].firstMatch.exists, - "Hiking category label must appear on the pack row" + app.staticTexts["Backpacking"].firstMatch.waitForExistence(timeout: 5), + "Selected Backpacking category label must appear on the pack row" ) } @@ -185,19 +183,41 @@ final class PackTests: AppUITestCase { // MARK: - Helpers - private func createPack(named name: String) { + private func createPack(named name: String, category: String? = nil) { goToTab("Packs") - waitFor(app.buttons["New Pack"]).tap() + waitFor(app.buttons["packs_new_pack_button"].firstMatch).tap() let nameField = app.textFields["pack_name"] waitFor(nameField) nameField.tap() nameField.typeText(name) + if let category { + selectPackCategory(category) + } + app.buttons["Create"].tap() waitFor(app.staticTexts[name], timeout: 15) } + private func selectPackCategory(_ category: String) { + let picker = app.buttons["pack_category"].firstMatch + let fallbackPicker = app.buttons.matching(NSPredicate(format: "label CONTAINS 'Category'")).firstMatch + let targetPicker = picker.waitForExistence(timeout: 2) ? picker : fallbackPicker + waitFor(targetPicker, timeout: 5, message: "Pack category picker must be visible") + targetPicker.tap() + + let option = app.buttons[category].firstMatch + let optionText = app.staticTexts[category].firstMatch + if option.waitForExistence(timeout: 5) { + option.tap() + } else { + waitFor(optionText, timeout: 5, message: "Pack category option '\(category)' must be visible").tap() + } + + app.navigationBars["Category"].buttons.element(boundBy: 0).tapIfExists() + } + private func openPack(named name: String) { goToTab("Packs") let cell = waitFor(app.staticTexts[name]) diff --git a/apps/swift/Tests/PackRatUITests/ScreenshotSmokeTests.swift b/apps/swift/Tests/PackRatUITests/ScreenshotSmokeTests.swift index f199282f31..b7c64f64a8 100644 --- a/apps/swift/Tests/PackRatUITests/ScreenshotSmokeTests.swift +++ b/apps/swift/Tests/PackRatUITests/ScreenshotSmokeTests.swift @@ -6,23 +6,23 @@ import XCTest /// are attached to the `.xcresult`; host-side PNG capture can be done with /// `xcrun simctl io screenshot`. final class ScreenshotSmokeTests: AppUITestCase { + override var additionalLaunchArguments: [String] { ["--ui-test-fixtures"] } + func testCaptureCoreScreens() throws { capture("02-home") - goToTab("Packs") - XCTAssertTrue(app.navigationBars["Packs"].waitForExistence(timeout: 8)) + goToDestination("Packs") + assertDestinationLoaded("Packs") 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…"] + goToDestination("Weather") + assertDestinationLoaded("Weather") + let searchField = app.searchFields["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 ','") + NSPredicate(format: "identifier BEGINSWITH 'weather_search_result_' AND label CONTAINS 'Denver'") ).firstMatch XCTAssertTrue(firstResult.waitForExistence(timeout: 10)) firstResult.tap() @@ -40,4 +40,27 @@ final class ScreenshotSmokeTests: AppUITestCase { attachment.lifetime = .keepAlways add(attachment) } + + private func goToDestination(_ label: String) { + #if os(macOS) + goToSidebar(label) + #else + goToTab(label) + #endif + } + + private func assertDestinationLoaded(_ label: String, file: StaticString = #filePath, line: UInt = #line) { + #if os(macOS) + switch label { + case "Packs": + XCTAssertTrue(app.buttons["New Pack"].waitForExistence(timeout: 8), file: file, line: line) + case "Weather": + XCTAssertTrue(app.searchFields["Search locations…"].waitForExistence(timeout: 8), file: file, line: line) + default: + XCTAssertTrue(app.staticTexts[label].waitForExistence(timeout: 8), file: file, line: line) + } + #else + XCTAssertTrue(app.navigationBars[label].waitForExistence(timeout: 8), file: file, line: line) + #endif + } } diff --git a/apps/swift/Tests/PackRatUITests/SeasonSuggestionsMacOSTests.swift b/apps/swift/Tests/PackRatUITests/SeasonSuggestionsMacOSTests.swift index 2792afd9a3..f53881dbf4 100644 --- a/apps/swift/Tests/PackRatUITests/SeasonSuggestionsMacOSTests.swift +++ b/apps/swift/Tests/PackRatUITests/SeasonSuggestionsMacOSTests.swift @@ -55,5 +55,34 @@ final class SeasonSuggestionsMacOSTests: AppUITestCase { app.buttons["Done"].tapIfExists() } + + func testGetSuggestionsReturnsDeterministicResults() { + goToSidebar("Home") + + let tile = app.buttons["home_action_seasonsuggestions"] + guard tile.waitForExistence(timeout: 8) else { + XCTFail("Season Suggestions tile not found on Home") + return + } + tile.click() + + let locationField = app.textFields["season_suggestions_location"] + waitFor(locationField, timeout: 5, message: "Season Suggestions location field must be visible") + locationField.click() + locationField.typeText("Yosemite") + + let getButton = app.buttons["season_suggestions_submit"] + waitFor(getButton, timeout: 5) + XCTAssertTrue(getButton.isEnabled, "Get Suggestions must enable when location is entered") + getButton.click() + + XCTAssertTrue( + app.descendants(matching: .any)["season_suggestions_results"].waitForExistence(timeout: 20) + || app.staticTexts["Shoulder Season Overnight"].waitForExistence(timeout: 2), + "Season Suggestions must render deterministic E2E results" + ) + + app.buttons["Done"].tapIfExists() + } } #endif diff --git a/apps/swift/Tests/PackRatUITests/SeasonSuggestionsTests.swift b/apps/swift/Tests/PackRatUITests/SeasonSuggestionsTests.swift index 1b57a58c39..0ef40eaedc 100644 --- a/apps/swift/Tests/PackRatUITests/SeasonSuggestionsTests.swift +++ b/apps/swift/Tests/PackRatUITests/SeasonSuggestionsTests.swift @@ -42,6 +42,28 @@ final class SeasonSuggestionsTests: AppUITestCase { app.buttons["Done"].tapIfExists() } + + func testGetSuggestionsReturnsDeterministicResults() { + goToHomeAction("Season Suggestions") + + let locationField = app.textFields["season_suggestions_location"] + waitFor(locationField, timeout: 5, message: "Season Suggestions location field must be visible") + locationField.tap() + locationField.typeText("Yosemite") + + let getButton = app.buttons["season_suggestions_submit"] + waitFor(getButton, timeout: 5) + XCTAssertTrue(getButton.isEnabled, "Get Suggestions must enable when location is entered") + getButton.tap() + + XCTAssertTrue( + app.descendants(matching: .any)["season_suggestions_results"].waitForExistence(timeout: 20) + || app.staticTexts["Shoulder Season Overnight"].waitForExistence(timeout: 2), + "Season Suggestions must render deterministic E2E results" + ) + + app.buttons["Done"].tapIfExists() + } } #endif diff --git a/apps/swift/Tests/PackRatUITests/VisualScreenshotTests.swift b/apps/swift/Tests/PackRatUITests/VisualScreenshotTests.swift index 4df5fd375e..876c20a225 100644 --- a/apps/swift/Tests/PackRatUITests/VisualScreenshotTests.swift +++ b/apps/swift/Tests/PackRatUITests/VisualScreenshotTests.swift @@ -1,3 +1,4 @@ +import CryptoKit import XCTest final class VisualScreenshotTests: XCTestCase { @@ -42,10 +43,13 @@ final class VisualScreenshotTests: XCTestCase { private var isPadVisualRun: Bool { #if os(iOS) - ProcessInfo.processInfo.environment["PACKRAT_VISUAL_PLATFORM"] == "ipad" + let bundle = Bundle(for: VisualScreenshotTests.self) + let platform = ProcessInfo.processInfo.environment["PACKRAT_VISUAL_PLATFORM"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_VISUAL_PLATFORM") as? String) + return platform == "ipad" || screenshotDirectory?.path.contains("ipad") == true #else - false + return false #endif } @@ -113,6 +117,20 @@ final class VisualScreenshotTests: XCTestCase { } #endif + func testGuestAccountLimitVisualSurface() throws { + enterGuestMode() + + #if os(iOS) + if isPadVisualRun { + captureSidebarGuestAccountLimits() + } else { + capturePhoneGuestAccountLimits() + } + #elseif os(macOS) + captureSidebarGuestAccountLimits() + #endif + } + func testAuthenticatedVisualSurface() throws { launchAuthenticated() capture("20-auth-home") @@ -404,7 +422,14 @@ final class VisualScreenshotTests: XCTestCase { let prefix = mode.prefix let suffix = mode.suffix for action in actions { - captureHomeAction(action.title, name: "\(prefix)-\(action.slug)\(suffix)") + let name = "\(prefix)-\(action.slug)\(suffix)" + if mode == .sampleData, action.title == "Season Suggestions" { + captureHomeAction(action.title, name: name, dismissAfterCapture: false) + captureSeasonSuggestionsResult(name: "\(prefix)-season-suggestions-results\(suffix)") + dismissPhoneDestination() + } else { + captureHomeAction(action.title, name: name) + } } } @@ -420,6 +445,7 @@ final class VisualScreenshotTests: XCTestCase { resetPhoneModalState(mode) captureTab("Packs", name: "\(prefix)-packs-before-new-pack") + resetPacksToMyPacksMode() tapAndCapture(identifier: "packs_new_pack_button", fallbackButton: "New Pack", name: "\(prefix)-new-pack-sheet") resetPhoneModalState(mode) @@ -566,10 +592,9 @@ final class VisualScreenshotTests: XCTestCase { } } - private func captureTab(_ label: String, name: String) { - let tab = app.tabBars.buttons[label] - XCTAssertTrue(tab.waitForExistence(timeout: 5), "Expected tab '\(label)' for screenshot \(name)") - tab.tap() + private func captureTab(_ label: String, name: String, beforeCapture: (() -> Void)? = nil) { + openPrimarySurface(label, screenshotName: name) + beforeCapture?() capture(name) } @@ -577,32 +602,44 @@ final class VisualScreenshotTests: XCTestCase { _ title: String, name: String, dismissAfterCapture: Bool = true, - destinationIdentifier: String? = nil + destinationIdentifier: String? = nil, + beforeCapture: (() -> Void)? = nil ) { let baselineName = name.hasPrefix("home-before-") ? name : "home-before-\(name)" openHomeForActionBaseline(name: baselineName) + let verifyAndCapture = { + if let destinationIdentifier { + let destination = self.app.descendants(matching: .any).matching(identifier: destinationIdentifier).firstMatch + XCTAssertTrue( + destination.waitForExistence(timeout: 5), + "Expected Home action '\(title)' to open '\(destinationIdentifier)' for screenshot \(name)" + ) + } + beforeCapture?() + self.capture(name) + if dismissAfterCapture { + self.dismissPhoneDestination() + } + } + let identifier = "home_action_\(title.lowercased().filter { $0.isLetter || $0.isNumber })" + if prefersHomeSearch(for: title) { + openHomeActionUsingSearch(title: title, identifier: identifier) + verifyAndCapture() + return + } + let action = app.buttons[identifier] var visibleCandidate: XCUIElement? - for _ in 0..<30 { + for _ in 0..<8 { if action.exists { visibleCandidate = action } if action.exists, action.isHittable, actionIsClearOfBottomBar(action) { activate(action) - if let destinationIdentifier { - let destination = app.descendants(matching: .any).matching(identifier: destinationIdentifier).firstMatch - XCTAssertTrue( - destination.waitForExistence(timeout: 5), - "Expected Home action '\(title)' to open '\(destinationIdentifier)' for screenshot \(name)" - ) - } - capture(name) - if dismissAfterCapture { - dismissPhoneDestination() - } + verifyAndCapture() return } if action.exists, action.frame.minY < 140 { @@ -611,34 +648,25 @@ final class VisualScreenshotTests: XCTestCase { smallScrollUp() } } - if let visibleCandidate, visibleCandidate.exists, visibleCandidate.isHittable { + if let visibleCandidate, visibleCandidate.exists { activate(visibleCandidate) - if let destinationIdentifier { - let destination = app.descendants(matching: .any).matching(identifier: destinationIdentifier).firstMatch - XCTAssertTrue( - destination.waitForExistence(timeout: 5), - "Expected Home action '\(title)' to open '\(destinationIdentifier)' for screenshot \(name)" - ) - } - capture(name) - if dismissAfterCapture { - dismissPhoneDestination() - } + verifyAndCapture() return } openHomeActionUsingSearch(title: title, identifier: identifier) - if let destinationIdentifier { - let destination = app.descendants(matching: .any).matching(identifier: destinationIdentifier).firstMatch - XCTAssertTrue( - destination.waitForExistence(timeout: 5), - "Expected Home action '\(title)' to open '\(destinationIdentifier)' for screenshot \(name)" - ) - } - capture(name) - if dismissAfterCapture { - dismissPhoneDestination() - } + verifyAndCapture() + } + + private func prefersHomeSearch(for title: String) -> Bool { + [ + "AI Packs", + "Community Feed", + "Gear Inventory", + "Pack Templates", + "Trail Conditions", + "Wildlife ID", + ].contains(title) } private func openHomeActionUsingSearch(title: String, identifier: String) { @@ -669,13 +697,69 @@ final class VisualScreenshotTests: XCTestCase { } private func openHomeForActionBaseline(name: String) { - let tab = app.tabBars.buttons["Home"] - XCTAssertTrue(tab.waitForExistence(timeout: 5), "Expected tab 'Home' for screenshot \(name)") - tab.tap() + openPrimarySurface("Home", screenshotName: name) resetActiveHomeSearchPresentation() capture(name) } + private func openPrimarySurface(_ label: String, screenshotName: String) { + #if os(iOS) + if isPadVisualRun { + let sidebarButton = app.buttons["nav_\(label.lowercased().replacingOccurrences(of: " ", with: "_"))"] + if sidebarButton.waitForExistence(timeout: 3) { + activate(sidebarButton) + return + } + } + + dismissKeyboardIfNeeded() + + let tab = app.tabBars.buttons[label] + XCTAssertTrue(tab.waitForExistence(timeout: 5), "Expected tab '\(label)' for screenshot \(screenshotName)") + tapTabBarButton(tab) + #elseif os(macOS) + let identifier = "nav_\(label.lowercased().replacingOccurrences(of: " ", with: "_"))" + let sidebarButton = app.buttons[identifier] + if sidebarButton.waitForExistence(timeout: 3) { + activate(sidebarButton) + return + } + + let sidebarText = app.staticTexts[label] + XCTAssertTrue(sidebarText.waitForExistence(timeout: 5), "Expected sidebar item '\(label)' for screenshot \(screenshotName)") + activate(sidebarText) + #endif + } + + private func dismissKeyboardIfNeeded() { + #if os(iOS) + guard app.keyboards.firstMatch.exists else { return } + if app.buttons["Cancel"].exists { + app.buttons["Cancel"].tap() + } + if app.buttons["Close"].exists { + app.buttons["Close"].tap() + } + #endif + } + + private func tapTabBarButton(_ element: XCUIElement) { + #if os(iOS) + let frame = element.frame + guard frame.width > 0, frame.height > 0 else { + activate(element) + return + } + let coordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) + .withOffset(CGVector(dx: frame.midX, dy: frame.midY)) + coordinate.tap() + Thread.sleep(forTimeInterval: 0.35) + coordinate.tap() + #else + activate(element) + #endif + } + private func replaceHomeSearchText(_ text: String, in searchField: XCUIElement) { clearHomeSearchText(in: searchField) searchField.typeText(text) @@ -731,6 +815,13 @@ final class VisualScreenshotTests: XCTestCase { #endif } + private func resetPacksToMyPacksMode() { + let myPacks = app.buttons["packs_mode_my_packs"] + if myPacks.waitForExistence(timeout: 1), myPacks.isHittable { + myPacks.tap() + } + } + private func actionIsClearOfBottomBar(_ element: XCUIElement) -> Bool { #if os(iOS) element.frame.minY > 140 && element.frame.midY < app.frame.maxY - 170 @@ -760,11 +851,35 @@ final class VisualScreenshotTests: XCTestCase { } private func captureGuestLimitedHomeAction(_ title: String, name: String) { - captureHomeAction(title, name: name, dismissAfterCapture: false) - assertExpectedAccountRequiredState(for: name) + captureHomeAction( + title, + name: name, + dismissAfterCapture: false, + beforeCapture: { self.assertExpectedAccountRequiredState(for: name) } + ) dismissPhoneDestination() } + private func captureGuestLimitedTab(_ label: String, name: String) { + captureTab(label, name: name) { + self.assertExpectedAccountRequiredState(for: name) + } + } + + private func capturePhoneGuestAccountLimits() { + captureGuestLimitedTab("Assistant", name: "50-guest-limit-assistant") + captureGuestLimitedHomeAction("AI Packs", name: "50-guest-limit-ai-packs") + captureGuestLimitedHomeAction("Catalog", name: "50-guest-limit-catalog") + captureGuestLimitedHomeAction("Weather", name: "50-guest-limit-weather") + + if UITestFeatureFlags.enableFeed { + captureGuestLimitedHomeAction("Community Feed", name: "50-guest-limit-feed") + } + if UITestFeatureFlags.enableWildlifeIdentification { + captureGuestLimitedHomeAction("Wildlife ID", name: "50-guest-limit-wildlife") + } + } + private func dismissPhoneDestination() { if app.buttons["Done"].exists { app.buttons["Done"].tap() @@ -815,7 +930,14 @@ final class VisualScreenshotTests: XCTestCase { } if scope != .primary { - captureMacHomeAction("Season Suggestions", name: "\(prefix)-season-suggestions\(suffix)") + let name = "\(prefix)-season-suggestions\(suffix)" + if mode == .sampleData { + captureMacHomeAction("Season Suggestions", name: name, dismissAfterCapture: false) + captureSeasonSuggestionsResult(name: "\(prefix)-season-suggestions-results\(suffix)") + dismissPresentedSurface() + } else { + captureMacHomeAction("Season Suggestions", name: name) + } } } @@ -872,6 +994,32 @@ final class VisualScreenshotTests: XCTestCase { } } + private func captureSidebarGuestAccountLimits() { + let entries = [ + ("Assistant", "50-guest-limit-assistant"), + ("Weather", "50-guest-limit-weather"), + ("Catalog", "50-guest-limit-catalog"), + ("AI Packs", "50-guest-limit-ai-packs"), + ] + + for (label, name) in entries { + selectSidebar(label) + assertExpectedAccountRequiredState(for: name) + capture(name) + } + + if UITestFeatureFlags.enableFeed { + selectSidebar("Feed") + assertExpectedAccountRequiredState(for: "50-guest-limit-feed") + capture("50-guest-limit-feed") + } + if UITestFeatureFlags.enableWildlifeIdentification { + selectSidebar("Wildlife") + assertExpectedAccountRequiredState(for: "50-guest-limit-wildlife") + capture("50-guest-limit-wildlife") + } + } + private func captureMacExpandedPackStates() { resetMacSampleDataSidebar("Packs") capture("81-data-pack-detail-expanded") @@ -990,6 +1138,27 @@ final class VisualScreenshotTests: XCTestCase { } XCTFail("Expected Home action '\(title)' for screenshot \(name)") } + + private func captureSeasonSuggestionsResult(name: String) { + let field = app.textFields["season_suggestions_location"].firstMatch + XCTAssertTrue(field.waitForExistence(timeout: 5), "Expected Season Suggestions location field for \(name)") + activate(field) + field.typeText("Leavenworth, WA") + + let submit = app.buttons["season_suggestions_submit"].firstMatch + XCTAssertTrue(submit.waitForExistence(timeout: 5), "Expected Season Suggestions submit button for \(name)") + activate(submit) + + let results = app.descendants(matching: .any) + .matching(identifier: "season_suggestions_results") + .firstMatch + let deterministicTitle = app.staticTexts["Shoulder Season Overnight"] + XCTAssertTrue( + results.waitForExistence(timeout: 20) || deterministicTitle.waitForExistence(timeout: 2), + "Expected Season Suggestions results for \(name)" + ) + capture(name) + } #endif private func captureGlobalSearch(name: String, query: String? = nil) { @@ -1039,6 +1208,7 @@ final class VisualScreenshotTests: XCTestCase { ) { let element = app.descendants(matching: .any).matching(identifier: identifier).firstMatch XCTAssertTrue(element.waitForExistence(timeout: 5), "Expected element identifier '\(identifier)' for screenshot \(name)") + XCTAssertTrue(element.isHittable, "Expected element identifier '\(identifier)' to be hittable for screenshot \(name)") activate(element) capture(name) if dismissAfterCapture { @@ -1048,7 +1218,7 @@ final class VisualScreenshotTests: XCTestCase { private func scrollToElement(identifier: String, maxSwipes: Int = 5) { let element = app.descendants(matching: .any).matching(identifier: identifier).firstMatch - for _ in 0.. XCUIElement? { + let query = app.descendants(matching: .any).matching(identifier: identifier) + return findConcreteElement(in: query, timeout: timeout) + } + private func findConcreteElement(in query: XCUIElementQuery, timeout: TimeInterval) -> XCUIElement? { guard query.firstMatch.waitForExistence(timeout: timeout) else { return nil } for element in query.allElementsBoundByIndex where element.exists && element.isHittable { @@ -1166,13 +1341,29 @@ final class VisualScreenshotTests: XCTestCase { #endif } + private func dismissTransientOverlay() { + #if os(macOS) + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + #else + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.22)).tap() + #endif + } + private func launchAuthenticated(sampleData: Bool = false, forceOffline: Bool = false) { let bundle = Bundle(for: VisualScreenshotTests.self) - let email = (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_EMAIL") as? String) + let email = ProcessInfo.processInfo.environment["PACKRAT_E2E_EMAIL"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_EMAIL") as? String) ?? "e2e@packrat.test" - let userId = ProcessInfo.processInfo.environment["E2E_TEST_USER_ID"] + let userId = ProcessInfo.processInfo.environment["PACKRAT_E2E_USER_ID"] + ?? ProcessInfo.processInfo.environment["E2E_TEST_USER_ID"] + ?? (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_USER_ID") as? String) ?? "00000000-0000-4000-8000-000000000001" + if usesRealVisualLogin && !sampleData && !forceOffline { + launchRealAuthenticated(email: email) + return + } + app.terminate() app = XCUIApplication() app.launchArguments = [ @@ -1182,8 +1373,11 @@ final class VisualScreenshotTests: XCTestCase { "--seed-e2e-auth", ] app.launchEnvironment["PACKRAT_VISUAL_SCREENSHOTS"] = "1" + app.launchEnvironment["E2E_API_BASE_URL"] = visualE2EAPIBaseURL app.launchEnvironment["PACKRAT_E2E_EMAIL"] = email app.launchEnvironment["PACKRAT_E2E_USER_ID"] = userId + app.launchEnvironment["PACKRAT_E2E_SESSION_TOKEN"] = visualE2ESessionToken(email: email, userId: userId) + app.launchEnvironment["PACKRAT_E2E_ALLOW_LOGIN_SEED"] = "1" if sampleData { app.launchArguments.append("--visual-sample-data") app.launchEnvironment["PACKRAT_VISUAL_SAMPLE_DATA"] = "1" @@ -1200,6 +1394,54 @@ final class VisualScreenshotTests: XCTestCase { XCTAssertTrue(waitForAuthenticatedShell(), "Authenticated visual shell must launch from seeded E2E state") } + private var usesRealVisualLogin: Bool { + let bundle = Bundle(for: VisualScreenshotTests.self) + return ProcessInfo.processInfo.environment["PACKRAT_VISUAL_AUTH_MODE"] == "real" + || (bundle.object(forInfoDictionaryKey: "PACKRAT_VISUAL_AUTH_MODE") as? String) == "real" + } + + private func launchRealAuthenticated(email: String) { + let bundle = Bundle(for: VisualScreenshotTests.self) + let password = (bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_PASSWORD") as? String) ?? "" + XCTAssertFalse(password.isEmpty, "PACKRAT_E2E_PASSWORD is required for real visual login") + + app.terminate() + app = XCUIApplication() + app.launchArguments = [ + "--disable-animations", + "--use-userdefaults-auth", + "--reset-auth", + ] + app.launchEnvironment["PACKRAT_VISUAL_SCREENSHOTS"] = "1" + app.launchEnvironment["E2E_API_BASE_URL"] = visualE2EAPIBaseURL + app.launch() + #if os(macOS) + app.activate() + dismissSystemInterruptions() + #endif + + let signIn = app.buttons["auth_sign_in"] + if signIn.waitForExistence(timeout: 10) { + signIn.tap() + } + + let emailField = app.textFields["login_email"] + XCTAssertTrue(emailField.waitForExistence(timeout: 10), "Login screen must appear for real visual login") + emailField.tap() + emailField.typeText(email) + + let passwordField = app.secureTextFields["login_password"] + passwordField.tap() + passwordField.typeText(password) + + #if os(macOS) + app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: []) + #endif + app.buttons["login_submit"].tap() + + XCTAssertTrue(waitForAuthenticatedShell(), "Authenticated visual shell must launch from real login") + } + private func waitForAuthenticatedShell() -> Bool { #if os(iOS) if isPadVisualRun { @@ -1224,6 +1466,7 @@ final class VisualScreenshotTests: XCTestCase { app.launchArguments.append("--force-offline") } app.launchEnvironment["PACKRAT_VISUAL_SCREENSHOTS"] = "1" + app.launchEnvironment["E2E_API_BASE_URL"] = visualE2EAPIBaseURL app.launch() #if os(macOS) app.activate() @@ -1231,6 +1474,31 @@ final class VisualScreenshotTests: XCTestCase { #endif } + private var visualE2EAPIBaseURL: String { + let bundle = Bundle(for: VisualScreenshotTests.self) + return ProcessInfo.processInfo.environment["E2E_API_BASE_URL"] + ?? (bundle.object(forInfoDictionaryKey: "E2E_API_BASE_URL") as? String) + ?? "http://localhost:8787" + } + + private func visualE2ESessionToken(email: String, userId: String) -> String { + if let token = ProcessInfo.processInfo.environment["PACKRAT_E2E_SESSION_TOKEN"], !token.isEmpty { + return token + } + let bundle = Bundle(for: VisualScreenshotTests.self) + if let token = bundle.object(forInfoDictionaryKey: "PACKRAT_E2E_SESSION_TOKEN") as? String, + !token.isEmpty { + return token + } + + let secret = ProcessInfo.processInfo.environment["BETTER_AUTH_SECRET"] + ?? "e2e-better-auth-secret-at-least-32-chars" + let material = "\(secret):\(email.lowercased()):\(userId)" + let digest = SHA256.hash(data: Data(material.utf8)) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return "e2e-local.\(hex)" + } + private func restartLoggedOut() { app.terminate() launchLoggedOut() @@ -1324,14 +1592,12 @@ final class VisualScreenshotTests: XCTestCase { @discardableResult private func dismissInterruption(in container: XCUIElement) -> Bool { #if os(macOS) + let allButtons = container.buttons.allElementsBoundByIndex for label in ["Remind Me Later", "Not Now", "Continue", "OK", "Allow", "Dismiss", "Close"] { - let button = container.buttons[label] - if button.exists { - if button.isHittable { - button.click() - } else { - button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() - } + let matchingButtons = allButtons + .filter { $0.label == label || $0.identifier == label } + if let button = matchingButtons.first(where: { $0.exists && $0.isHittable }) ?? matchingButtons.first(where: { $0.exists }) { + button.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click() return true } } diff --git a/apps/swift/Tests/PackRatUITests/WeatherTests.swift b/apps/swift/Tests/PackRatUITests/WeatherTests.swift index c646444433..9e81a1091a 100644 --- a/apps/swift/Tests/PackRatUITests/WeatherTests.swift +++ b/apps/swift/Tests/PackRatUITests/WeatherTests.swift @@ -5,7 +5,9 @@ import XCTest /// End-to-end tests for Weather: location search, forecast display, saved locations. final class WeatherTests: AppUITestCase { - override var additionalLaunchArguments: [String] { ["--ui-test-fixtures"] } + override var additionalLaunchArguments: [String] { + ["--ui-test-fixtures", "-temperatureUnit", "°C"] + } private let testCity = "Denver" private let testCityFull = "Denver" // fragment to match in search results @@ -128,6 +130,32 @@ final class WeatherTests: AppUITestCase { ) } + func testCelsiusPreferenceAppliesToCurrentFeelsLikeAndForecast() { + goToWeather() + + let searchField = app.searchFields["Search locations\u{2026}"] + waitFor(searchField) + searchField.tap() + searchField.typeText(testCity) + + let firstResult = weatherSearchResults().firstMatch + waitFor(firstResult, timeout: weatherSearchTimeout) + firstResult.tap() + + let currentCard = app.descendants(matching: .any)["weather_current_card"] + XCTAssertTrue(currentCard.waitForExistence(timeout: 20)) + assertCelsiusValue(identifier: "weather_current_temperature") + assertCelsiusValue(identifier: "weather_feels_like_temperature") + + let list = app.collectionViews.firstMatch + for _ in 0..<4 where !app.staticTexts["10-Day Forecast"].exists { + list.swipeUp() + } + XCTAssertTrue(app.staticTexts["10-Day Forecast"].waitForExistence(timeout: 20)) + assertCelsiusValue(identifierPrefix: "weather_forecast_high_") + assertCelsiusValue(identifierPrefix: "weather_forecast_low_") + } + func testWeatherAlertsButtonAppearsWithForecast() { goToWeather() @@ -164,6 +192,20 @@ final class WeatherTests: AppUITestCase { NSPredicate(format: "identifier BEGINSWITH 'weather_search_result_' AND label CONTAINS '\(testCityFull)'") ) } + + private func assertCelsiusValue(identifier: String) { + let value = app.descendants(matching: .any)[identifier] + XCTAssertTrue(value.waitForExistence(timeout: 5)) + XCTAssertTrue(value.label.hasSuffix("°C"), "\(identifier) should use Celsius") + } + + private func assertCelsiusValue(identifierPrefix: String) { + let value = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", identifierPrefix) + ).firstMatch + XCTAssertTrue(value.waitForExistence(timeout: 5)) + XCTAssertTrue(value.label.hasSuffix("°C"), "\(identifierPrefix) values should use Celsius") + } } #endif diff --git a/apps/swift/docs/staging-adhoc.md b/apps/swift/docs/staging-adhoc.md index 6e569d6693..04d17cfcb6 100644 --- a/apps/swift/docs/staging-adhoc.md +++ b/apps/swift/docs/staging-adhoc.md @@ -43,9 +43,9 @@ provisioning profile can install the build. |---|---| | `IOS_DIST_CERT_P12` | base64 of an Apple **Distribution** certificate + private key exported as `.p12` | | `IOS_DIST_CERT_PASSWORD` | the password you set when exporting the `.p12` | -| `IOS_ADHOC_PROVISIONING_PROFILE` | base64 of an **ad-hoc** `.mobileprovision` for `com.andrewbierman.packrat.swift`, listing QA device UDIDs | +| `IOS_ADHOC_PROVISIONING_PROFILE` | base64 of an **ad-hoc** `.mobileprovision` for `com.andrewbierman.packrat`, listing QA device UDIDs | -Team ID `666HGMV2LU` and bundle id `com.andrewbierman.packrat.swift` are hard-coded in +Team ID `666HGMV2LU` and bundle id `com.andrewbierman.packrat` are hard-coded in the workflow's export options — update them there if they change. Both the cert and the profile are created **fresh** here (an admin on the Apple @@ -75,7 +75,7 @@ fastlane run cert \ # devices → writes a .mobileprovision. fastlane run sigh \ adhoc:true \ - app_identifier:com.andrewbierman.packrat.swift \ + app_identifier:com.andrewbierman.packrat \ team_id:666HGMV2LU \ output_path:./signing ``` @@ -97,7 +97,7 @@ Portal home: **developer.apple.com/account → Certificates, Identifiers & Profiles**. Left menu has **Certificates · Identifiers · Devices · Profiles**. 1. **Devices** → **+** → register each QA device (name + UDID). -2. **Identifiers** → confirm an App ID for `com.andrewbierman.packrat.swift` +2. **Identifiers** → confirm an App ID for `com.andrewbierman.packrat` exists; if not, **+** → App IDs → App → Explicit bundle id → Register. 3. **Certificates** → **+** → **Apple Distribution** → follow the CSR steps (Keychain Access → Certificate Assistant → Request a Certificate from a CA), @@ -105,9 +105,9 @@ Profiles**. Left menu has **Certificates · Identifiers · Devices · Profiles** In **Keychain Access**, find the cert, expand it, select **both** the cert and its private key → right-click → **Export 2 items** → save as `.p12` (set a password = `IOS_DIST_CERT_PASSWORD`). -4. **Profiles** → **+** → **Ad Hoc** → App ID `com.andrewbierman.packrat.swift` +4. **Profiles** → **+** → **Ad Hoc** → App ID `com.andrewbierman.packrat` → select the distribution cert from step 3 → check every registered device → - name it `PackRat Swift Ad Hoc` → **Generate** → **Download**. + name it `PackRat Ad Hoc` → **Generate** → **Download**. Encode both: diff --git a/apps/swift/project.yml b/apps/swift/project.yml index 10a553b076..1f460ed5ed 100644 --- a/apps/swift/project.yml +++ b/apps/swift/project.yml @@ -24,7 +24,7 @@ configFiles: packages: Nuke: url: https://github.com/kean/Nuke - from: "12.0.0" + exactVersion: "12.8.0" MarkdownUI: url: https://github.com/gonzalezreal/swift-markdown-ui from: "2.4.0" @@ -61,20 +61,30 @@ targets: sources: - Sources/PackRat - Sources/PackRatShared - - Resources/Assets.xcassets + - path: Resources/Assets.xcassets + buildPhase: resources + - path: Resources/PrivacyInfo.xcprivacy + buildPhase: resources entitlements: path: Resources/PackRat-iOS.entitlements - properties: {} + properties: + com.apple.developer.applesignin: + - Default info: path: Resources/Info-iOS.plist properties: - CFBundleDisplayName: PackRat - CFBundleShortVersionString: "1.0" - CFBundleVersion: "1" + CFBundleDisplayName: $(PACKRAT_DISPLAY_NAME) + CFBundleIconName: AppIcon + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS: true # All four orientations are required for iPad (device family "1,2"); # App Store validation (error 90474) rejects the bundle otherwise. UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft @@ -84,6 +94,8 @@ targets: UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: true NSLocationWhenInUseUsageDescription: "This app needs access to your location while you are using it." + NSCameraUsageDescription: "This app requires access to your camera to let you take photos or scan items." + NSPhotoLibraryUsageDescription: "This app needs access to your photo library to let you upload or choose photos." ITSAppUsesNonExemptEncryption: false PACKRAT_ENV: $(PACKRAT_ENV) SENTRY_DSN: $(SENTRY_DSN) @@ -125,17 +137,24 @@ targets: product: Sentry - package: GoogleSignIn product: GoogleSignIn - # PackRat-Watch is intentionally NOT embedded in distribution builds yet: - # the watch app has no app icons, which App Store validation rejects - # (error 90391). Re-add this dependency once watch icons exist. + - target: PackRat-Watch + embed: true + codeSign: true + copy: + destination: wrapper + subpath: Watch settings: base: SWIFT_VERSION: "5.9" - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "2.2.0" + CURRENT_PROJECT_VERSION: "2026080901" CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: 666HGMV2LU - PRODUCT_BUNDLE_IDENTIFIER: com.andrewbierman.packrat.swift + PACKRAT_IOS_BUNDLE_IDENTIFIER: com.andrewbierman.packrat + PRODUCT_BUNDLE_IDENTIFIER: $(PACKRAT_IOS_BUNDLE_IDENTIFIER) + PACKRAT_COMPANION_BUNDLE_IDENTIFIER: $(PACKRAT_IOS_BUNDLE_IDENTIFIER) + PACKRAT_WATCH_BUNDLE_IDENTIFIER: com.andrewbierman.packrat.watchkitapp + PACKRAT_DISPLAY_NAME: PackRat PRODUCT_MODULE_NAME: PackRat TARGETED_DEVICE_FAMILY: "1,2" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon @@ -147,24 +166,35 @@ targets: sources: - Sources/PackRatWatch - Sources/PackRatShared + - path: Resources/Assets.xcassets + buildPhase: resources + - path: Resources/WatchAssets.xcassets + buildPhase: resources + - path: Resources/PrivacyInfo.xcprivacy + buildPhase: resources info: path: Resources/Info-watchOS.plist properties: - CFBundleDisplayName: PackRat - CFBundleShortVersionString: "1.0" - CFBundleVersion: "1" + CFBundleDisplayName: $(PACKRAT_DISPLAY_NAME) + CFBundleIconName: WatchAppIcon + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) WKApplication: true - WKCompanionAppBundleIdentifier: com.andrewbierman.packrat.swift + WKCompanionAppBundleIdentifier: $(PACKRAT_COMPANION_BUNDLE_IDENTIFIER) ITSAppUsesNonExemptEncryption: false settings: base: SWIFT_VERSION: "5.9" - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "2.2.0" + CURRENT_PROJECT_VERSION: "2026080901" CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: 666HGMV2LU - PRODUCT_BUNDLE_IDENTIFIER: com.andrewbierman.packrat.swift.watchkitapp + PACKRAT_WATCH_BUNDLE_IDENTIFIER: com.andrewbierman.packrat.watchkitapp + PRODUCT_BUNDLE_IDENTIFIER: $(PACKRAT_WATCH_BUNDLE_IDENTIFIER) + PACKRAT_COMPANION_BUNDLE_IDENTIFIER: com.andrewbierman.packrat + PACKRAT_DISPLAY_NAME: PackRat PRODUCT_MODULE_NAME: PackRatWatch + ASSETCATALOG_COMPILER_APPICON_NAME: WatchAppIcon PackRat-macOS: type: application @@ -173,8 +203,10 @@ targets: sources: - Sources/PackRat - Sources/PackRatShared - resources: - - Resources/Assets.xcassets + - path: Resources/Assets.xcassets + buildPhase: resources + - path: Resources/PrivacyInfo.xcprivacy + buildPhase: resources entitlements: path: Resources/PackRat-macOS.entitlements properties: @@ -183,9 +215,10 @@ targets: info: path: Resources/Info-macOS.plist properties: - CFBundleDisplayName: PackRat - CFBundleShortVersionString: "1.0" - CFBundleVersion: "1" + CFBundleDisplayName: $(PACKRAT_DISPLAY_NAME) + CFBundleIconName: AppIcon + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) NSPrincipalClass: NSApplication NSHighResolutionCapable: true PACKRAT_ENV: $(PACKRAT_ENV) @@ -214,15 +247,17 @@ targets: settings: base: SWIFT_VERSION: "5.9" - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "2.2.0" + CURRENT_PROJECT_VERSION: "2026080901" CODE_SIGN_STYLE: Manual DEVELOPMENT_TEAM: "" CODE_SIGN_IDENTITY: "-" CODE_SIGNING_REQUIRED: NO PRODUCT_BUNDLE_IDENTIFIER: com.andrewbierman.packrat.mac + PACKRAT_DISPLAY_NAME: PackRat # Match iOS target so @testable import PackRat resolves on both platforms. PRODUCT_MODULE_NAME: PackRat + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon PackRatTests: type: bundle.unit-test @@ -259,19 +294,27 @@ targets: CFBundlePackageType: BNDL CFBundleShortVersionString: "1.0" CFBundleVersion: "1" + E2E_API_BASE_URL: $(E2E_API_BASE_URL) PACKRAT_E2E_EMAIL: $(PACKRAT_E2E_EMAIL) PACKRAT_E2E_PASSWORD: $(PACKRAT_E2E_PASSWORD) PACKRAT_E2E_SESSION_TOKEN: $(PACKRAT_E2E_SESSION_TOKEN) PACKRAT_E2E_USER_ID: $(PACKRAT_E2E_USER_ID) + PACKRAT_E2E_ALLOW_LOGIN_SEED: $(PACKRAT_E2E_ALLOW_LOGIN_SEED) PACKRAT_SCREENSHOT_DIR: $(PACKRAT_SCREENSHOT_DIR) + PACKRAT_VISUAL_AUTH_MODE: $(PACKRAT_VISUAL_AUTH_MODE) + PACKRAT_VISUAL_PLATFORM: $(PACKRAT_VISUAL_PLATFORM) settings: base: SWIFT_VERSION: "5.9" + E2E_API_BASE_URL: "" PACKRAT_E2E_EMAIL: "" PACKRAT_E2E_PASSWORD: "" PACKRAT_E2E_SESSION_TOKEN: "" PACKRAT_E2E_USER_ID: "" + PACKRAT_E2E_ALLOW_LOGIN_SEED: "" PACKRAT_SCREENSHOT_DIR: "" + PACKRAT_VISUAL_AUTH_MODE: "" + PACKRAT_VISUAL_PLATFORM: "" PackRatMacOSTests: type: bundle.unit-test @@ -304,19 +347,27 @@ targets: CFBundlePackageType: BNDL CFBundleShortVersionString: "1.0" CFBundleVersion: "1" + E2E_API_BASE_URL: $(E2E_API_BASE_URL) PACKRAT_E2E_EMAIL: $(PACKRAT_E2E_EMAIL) PACKRAT_E2E_PASSWORD: $(PACKRAT_E2E_PASSWORD) PACKRAT_E2E_SESSION_TOKEN: $(PACKRAT_E2E_SESSION_TOKEN) PACKRAT_E2E_USER_ID: $(PACKRAT_E2E_USER_ID) + PACKRAT_E2E_ALLOW_LOGIN_SEED: $(PACKRAT_E2E_ALLOW_LOGIN_SEED) PACKRAT_SCREENSHOT_DIR: $(PACKRAT_SCREENSHOT_DIR) + PACKRAT_VISUAL_AUTH_MODE: $(PACKRAT_VISUAL_AUTH_MODE) + PACKRAT_VISUAL_PLATFORM: $(PACKRAT_VISUAL_PLATFORM) settings: base: SWIFT_VERSION: "5.9" + E2E_API_BASE_URL: "" PACKRAT_E2E_EMAIL: "" PACKRAT_E2E_PASSWORD: "" PACKRAT_E2E_SESSION_TOKEN: "" PACKRAT_E2E_USER_ID: "" + PACKRAT_E2E_ALLOW_LOGIN_SEED: "" PACKRAT_SCREENSHOT_DIR: "" + PACKRAT_VISUAL_AUTH_MODE: "" + PACKRAT_VISUAL_PLATFORM: "" schemes: PackRat-iOS: @@ -330,6 +381,7 @@ schemes: - path: TestPlans/iOS-Full.xctestplan defaultPlan: true - path: TestPlans/iOS-Smoke.xctestplan + - path: TestPlans/iOS-Sanity.xctestplan targets: - PackRatTests - PackRatUITests diff --git a/apps/swift/scripts/__tests__/app-store-assets.test.ts b/apps/swift/scripts/__tests__/app-store-assets.test.ts index 4a019fc6f3..479fb19a69 100644 --- a/apps/swift/scripts/__tests__/app-store-assets.test.ts +++ b/apps/swift/scripts/__tests__/app-store-assets.test.ts @@ -84,4 +84,32 @@ describe('validateAppIconSet', () => { 'AppIcon-iOS-1024.png has an alpha channel; App Store app icons must be flattened.', ]); }); + + it('accepts fractional Apple icon point sizes', () => { + const dir = createIconSet([ + { filename: 'AppIcon-iPad-83.5@2x.png', idiom: 'ipad', scale: '2x', size: '83.5x83.5' }, + { + filename: 'AppIcon-watch-notification-27.5@2x.png', + idiom: 'watch', + scale: '2x', + size: '27.5x27.5', + }, + ]); + writePlaceholder(dir, 'AppIcon-iPad-83.5@2x.png'); + writePlaceholder(dir, 'AppIcon-watch-notification-27.5@2x.png'); + + expect( + validateAppIconSet( + dir, + inspector({ + 'AppIcon-iPad-83.5@2x.png': { width: 167, height: 167, hasAlpha: false }, + 'AppIcon-watch-notification-27.5@2x.png': { + width: 55, + height: 55, + hasAlpha: false, + }, + }), + ), + ).toEqual([]); + }); }); diff --git a/apps/swift/scripts/__tests__/args.test.ts b/apps/swift/scripts/__tests__/args.test.ts index ec586805d8..1d2d93faca 100644 --- a/apps/swift/scripts/__tests__/args.test.ts +++ b/apps/swift/scripts/__tests__/args.test.ts @@ -10,11 +10,16 @@ describe('parseArgs', () => { expect(parseArgs(['--plan', 'smoke'])).toEqual({ plan: 'iOS-Smoke', passthrough: [] }); }); + it('resolves --plan sanity to iOS-Sanity', () => { + expect(parseArgs(['--plan', 'sanity'])).toEqual({ plan: 'iOS-Sanity', passthrough: [] }); + }); + it('resolves --plan full to iOS-Full', () => { expect(parseArgs(['--plan', 'full'])).toEqual({ plan: 'iOS-Full', passthrough: [] }); }); - it('accepts the canonical iOS-Smoke and iOS-Full names', () => { + it('accepts the canonical iOS-Sanity, iOS-Smoke, and iOS-Full names', () => { + expect(parseArgs(['--plan', 'iOS-Sanity']).plan).toBe('iOS-Sanity'); expect(parseArgs(['--plan', 'iOS-Smoke']).plan).toBe('iOS-Smoke'); expect(parseArgs(['--plan', 'iOS-Full']).plan).toBe('iOS-Full'); }); @@ -23,7 +28,37 @@ describe('parseArgs', () => { expect(parseArgs(['--plan=smoke']).plan).toBe('iOS-Smoke'); }); + it('accepts legacy positional iOS mode aliases used by package scripts', () => { + expect(parseArgs(['ios-ui'])).toEqual({ plan: 'iOS-Full', passthrough: [] }); + expect(parseArgs(['ios-smoke'])).toEqual({ plan: 'iOS-Smoke', passthrough: [] }); + expect(parseArgs(['ios-sanity'])).toEqual({ plan: 'iOS-Sanity', passthrough: [] }); + }); + + it('maps unit mode to the iOS unit test target instead of an xcodebuild action', () => { + expect(parseArgs(['unit'])).toEqual({ + passthrough: ['-only-testing:PackRatTests'], + }); + expect(parseArgs(['ios-unit'])).toEqual({ + passthrough: ['-only-testing:PackRatTests'], + }); + }); + + it('preserves an explicit plan before a unit positional mode', () => { + expect(parseArgs(['--plan', 'smoke', 'unit'])).toEqual({ + plan: 'iOS-Smoke', + passthrough: ['-only-testing:PackRatTests'], + }); + }); + + it('preserves an explicit plan after a unit positional mode', () => { + expect(parseArgs(['ios-unit', '--plan=full'])).toEqual({ + plan: 'iOS-Full', + passthrough: ['-only-testing:PackRatTests'], + }); + }); + it('case-insensitive alias matching', () => { + expect(parseArgs(['--plan', 'SANITY']).plan).toBe('iOS-Sanity'); expect(parseArgs(['--plan', 'SMOKE']).plan).toBe('iOS-Smoke'); expect(parseArgs(['--plan', 'FULL']).plan).toBe('iOS-Full'); }); diff --git a/apps/swift/scripts/__tests__/testflight-binary.test.ts b/apps/swift/scripts/__tests__/testflight-binary.test.ts new file mode 100644 index 0000000000..665d8d0739 --- /dev/null +++ b/apps/swift/scripts/__tests__/testflight-binary.test.ts @@ -0,0 +1,85 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { verifyTestFlightArchive } from '../lib/testflight-binary'; +import { parseTestFlightUploadConfig } from '../lib/testflight-config'; + +const replacementConfig = parseTestFlightUploadConfig({ + argv: ['--replacement', '--production'], + env: { BUILD_NUMBER: '2026071802' }, +}); + +function plist(values: Record): string { + const body = Object.entries(values) + .map(([key, value]) => `\t${key}\n\t${value}`) + .join('\n'); + return ` + + + +${body} + + +`; +} + +function writeArchive(input?: { iosBundleId?: string; packratEnv?: string }): string { + const root = mkdtempSync(join(tmpdir(), 'packrat-archive-test-')); + const iosApp = join(root, 'PackRat.xcarchive', 'Products', 'Applications', 'PackRat.app'); + const watchApp = join(iosApp, 'Watch', 'PackRat Watch.app'); + mkdirSync(watchApp, { recursive: true }); + writeFileSync( + join(iosApp, 'Info.plist'), + plist({ + CFBundleIdentifier: input?.iosBundleId ?? 'com.andrewbierman.packrat', + CFBundleDisplayName: 'PackRat', + CFBundleVersion: '2026071802', + PACKRAT_ENV: input?.packratEnv ?? 'production', + }), + ); + writeFileSync( + join(watchApp, 'Info.plist'), + plist({ + CFBundleIdentifier: 'com.andrewbierman.packrat.watchkitapp', + CFBundleDisplayName: 'PackRat', + CFBundleVersion: '2026071802', + WKCompanionAppBundleIdentifier: 'com.andrewbierman.packrat', + }), + ); + return join(root, 'PackRat.xcarchive'); +} + +describe('TestFlight binary verification', () => { + it('accepts a replacement archive with production metadata', () => { + const archivePath = writeArchive(); + try { + const result = verifyTestFlightArchive({ archivePath, config: replacementConfig }); + expect(result).toMatchObject({ + ok: true, + errors: [], + }); + expect(result.iosApp).toContain('PackRat.app'); + expect(result.watchApp).toContain('PackRat Watch.app'); + } finally { + rmSync(join(archivePath, '..'), { recursive: true, force: true }); + } + }); + + it('rejects an archive that still has side-by-side or non-production metadata', () => { + const archivePath = writeArchive({ + iosBundleId: 'com.andrewbierman.packrat.swift', + packratEnv: 'dev', + }); + try { + const result = verifyTestFlightArchive({ archivePath, config: replacementConfig }); + expect(result.ok).toBe(false); + expect(result.errors).toContain( + 'iOS bundle id: expected com.andrewbierman.packrat, got com.andrewbierman.packrat.swift.', + ); + expect(result.errors).toContain('iOS API environment: expected production, got dev.'); + } finally { + rmSync(join(archivePath, '..'), { recursive: true, force: true }); + } + }); +}); diff --git a/apps/swift/scripts/__tests__/testflight-config.test.ts b/apps/swift/scripts/__tests__/testflight-config.test.ts new file mode 100644 index 0000000000..060aa7b62d --- /dev/null +++ b/apps/swift/scripts/__tests__/testflight-config.test.ts @@ -0,0 +1,222 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + parseTestFlightUploadConfig, + TestFlightConfigError, + verifyTestFlightReplacementReadiness, + xcodeArchiveOverrides, +} from '../lib/testflight-config'; + +// The default marketing version is derived from the monorepo version so a +// `bun bump` cannot silently desync the Swift TestFlight uploads. Read it here +// too rather than hardcoding, so these tests survive the next bump. +const monorepoVersion = ( + JSON.parse(readFileSync(resolve(__dirname, '../../../../package.json'), 'utf-8')) as { + version: string; + } +).version; + +describe('parseTestFlightUploadConfig', () => { + it('requires an explicit TestFlight lane', () => { + expect(() => parseTestFlightUploadConfig({ argv: [], env: { BUILD_NUMBER: '123' } })).toThrow( + TestFlightConfigError, + ); + }); + + it('rejects conflicting lanes', () => { + expect(() => + parseTestFlightUploadConfig({ + argv: ['--side-by-side', '--replacement'], + env: { BUILD_NUMBER: '123' }, + }), + ).toThrow('Choose exactly one TestFlight lane'); + }); + + it('builds the side-by-side Swift beta identity', () => { + expect( + parseTestFlightUploadConfig({ + argv: ['--side-by-side'], + env: { BUILD_NUMBER: '123' }, + }), + ).toMatchObject({ + lane: 'side-by-side', + staging: false, + dryRun: false, + scheme: 'PackRat-iOS', + configuration: 'Release', + bundleId: 'com.andrewbierman.packrat.swift', + watchBundleId: 'com.andrewbierman.packrat.swift.watchkitapp', + companionBundleId: 'com.andrewbierman.packrat.swift', + displayName: 'PackRat Swift', + marketingVersion: monorepoVersion, + buildNumber: '123', + apiEnvironment: 'production', + }); + }); + + it('builds the replacement identity for the existing Expo listing', () => { + const config = parseTestFlightUploadConfig({ + argv: ['--replacement'], + env: { BUILD_NUMBER: '456' }, + }); + + expect(config).toMatchObject({ + lane: 'replacement', + staging: false, + dryRun: false, + scheme: 'PackRat-iOS', + configuration: 'Release', + bundleId: 'com.andrewbierman.packrat', + watchBundleId: 'com.andrewbierman.packrat.watchkitapp', + companionBundleId: 'com.andrewbierman.packrat', + displayName: 'PackRat', + marketingVersion: monorepoVersion, + buildNumber: '456', + apiEnvironment: 'production', + }); + expect(xcodeArchiveOverrides({ config, teamId: 'TEAM123' })).toEqual([ + `MARKETING_VERSION=${monorepoVersion}`, + 'CURRENT_PROJECT_VERSION=456', + 'DEVELOPMENT_TEAM=TEAM123', + 'PACKRAT_IOS_BUNDLE_IDENTIFIER=com.andrewbierman.packrat', + 'PACKRAT_WATCH_BUNDLE_IDENTIFIER=com.andrewbierman.packrat.watchkitapp', + 'PACKRAT_COMPANION_BUNDLE_IDENTIFIER=com.andrewbierman.packrat', + 'PACKRAT_DISPLAY_NAME=PackRat', + ]); + }); + + it('uses the staging scheme without changing the selected lane identity', () => { + expect( + parseTestFlightUploadConfig({ + argv: ['--replacement', '--staging'], + env: { BUILD_NUMBER: '789' }, + }), + ).toMatchObject({ + lane: 'replacement', + staging: true, + dryRun: false, + scheme: 'PackRat-iOS-Staging', + configuration: 'Staging', + bundleId: 'com.andrewbierman.packrat', + watchBundleId: 'com.andrewbierman.packrat.watchkitapp', + companionBundleId: 'com.andrewbierman.packrat', + displayName: 'PackRat', + apiEnvironment: 'dev', + }); + }); + + it('supports dry-run preflight without changing identity', () => { + expect( + parseTestFlightUploadConfig({ + argv: ['--replacement', '--dry-run'], + env: { BUILD_NUMBER: '101' }, + }), + ).toMatchObject({ + lane: 'replacement', + dryRun: true, + bundleId: 'com.andrewbierman.packrat', + displayName: 'PackRat', + marketingVersion: monorepoVersion, + apiEnvironment: 'production', + buildNumber: '101', + }); + }); + + it('allows an explicit marketing version override for controlled release testing', () => { + const config = parseTestFlightUploadConfig({ + argv: ['--replacement', '--production'], + env: { BUILD_NUMBER: '2026072101', MARKETING_VERSION: '2.1.1' }, + }); + + expect(config.marketingVersion).toBe('2.1.1'); + expect(xcodeArchiveOverrides({ config, teamId: 'TEAM123' })).toContain( + 'MARKETING_VERSION=2.1.1', + ); + }); + + it('rejects conflicting API profile flags', () => { + expect(() => + parseTestFlightUploadConfig({ argv: ['--replacement', '--staging', '--production'] }), + ).toThrow('Use either --staging or --production, not both.'); + }); + + it('verifies replacement settings for seamless TestFlight update', () => { + const config = parseTestFlightUploadConfig({ + argv: ['--replacement', '--production'], + env: { BUILD_NUMBER: '2026071802' }, + }); + + expect( + verifyTestFlightReplacementReadiness({ + config, + currentAppStoreBuildNumber: '2026071801', + }), + ).toEqual({ ok: true, errors: [], warnings: [] }); + }); + + it('rejects side-by-side settings for replacement readiness', () => { + const config = parseTestFlightUploadConfig({ + argv: ['--side-by-side', '--production'], + env: { BUILD_NUMBER: '2026071802' }, + }); + + const readiness = verifyTestFlightReplacementReadiness({ + config, + currentAppStoreBuildNumber: '2026071801', + }); + + expect(readiness.ok).toBe(false); + expect(readiness.errors).toContain( + 'Use --replacement. Side-by-side Swift beta builds cannot update the Expo app.', + ); + }); + + it('rejects stale replacement build numbers', () => { + const config = parseTestFlightUploadConfig({ + argv: ['--replacement', '--production'], + env: { BUILD_NUMBER: '2026071801' }, + }); + + const readiness = verifyTestFlightReplacementReadiness({ + config, + currentAppStoreBuildNumber: '2026071801', + }); + + expect(readiness.ok).toBe(false); + expect(readiness.errors).toContain( + 'Build number 2026071801 must be greater than current App Store/TestFlight build 2026071801.', + ); + }); + + it('warns when current App Store build is not supplied', () => { + const config = parseTestFlightUploadConfig({ + argv: ['--replacement', '--production'], + env: { BUILD_NUMBER: '2026071802' }, + }); + + const readiness = verifyTestFlightReplacementReadiness({ config }); + + expect(readiness.ok).toBe(true); + expect(readiness.warnings).toContain( + 'APP_STORE_CURRENT_BUILD_NUMBER was not provided; verify the replacement build number is greater than the latest App Store Connect build before upload.', + ); + }); + + it('rejects missing current App Store build when strict replacement readiness is required', () => { + const config = parseTestFlightUploadConfig({ + argv: ['--replacement', '--production'], + env: { BUILD_NUMBER: '2026071802' }, + }); + + const readiness = verifyTestFlightReplacementReadiness({ + config, + requireCurrentAppStoreBuildNumber: true, + }); + + expect(readiness.ok).toBe(false); + expect(readiness.errors).toContain( + 'APP_STORE_CURRENT_BUILD_NUMBER was not provided; verify the replacement build number is greater than the latest App Store Connect build before upload.', + ); + }); +}); diff --git a/apps/swift/scripts/__tests__/testflight-export.test.ts b/apps/swift/scripts/__tests__/testflight-export.test.ts new file mode 100644 index 0000000000..062dc54dc5 --- /dev/null +++ b/apps/swift/scripts/__tests__/testflight-export.test.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { findExportedIPA, TestFlightExportError } from '../lib/testflight-export'; + +let tempDirs: string[] = []; + +function makeTempDir() { + const dir = mkdtempSync(join(tmpdir(), 'packrat-testflight-export-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; +}); + +describe('findExportedIPA', () => { + it('returns the exported ipa regardless of scheme name', () => { + const dir = makeTempDir(); + writeFileSync(join(dir, 'PackRat.ipa'), ''); + + expect(basename(findExportedIPA(dir))).toBe('PackRat.ipa'); + }); + + it('fails when export did not produce an ipa', () => { + const dir = makeTempDir(); + writeFileSync(join(dir, 'ExportOptions.plist'), ''); + + expect(() => findExportedIPA(dir)).toThrow(TestFlightExportError); + expect(() => findExportedIPA(dir)).toThrow('No .ipa file found'); + }); + + it('fails when export produced multiple ipa files', () => { + const dir = makeTempDir(); + writeFileSync(join(dir, 'PackRat.ipa'), ''); + writeFileSync(join(dir, 'PackRat Swift.ipa'), ''); + + expect(() => findExportedIPA(dir)).toThrow('Expected one .ipa file'); + }); +}); diff --git a/apps/swift/scripts/__tests__/testflight-upload-cli.test.ts b/apps/swift/scripts/__tests__/testflight-upload-cli.test.ts new file mode 100644 index 0000000000..cd5178c3ec --- /dev/null +++ b/apps/swift/scripts/__tests__/testflight-upload-cli.test.ts @@ -0,0 +1,102 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { env as currentEnv } from 'node:process'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = resolve(__dirname, '../../../..'); +// Derived, not hardcoded — see the note in testflight-config.test.ts. +const monorepoVersion = ( + JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf-8')) as { version: string } +).version; +const script = resolve(repoRoot, 'apps/swift/scripts/upload-testflight.ts'); +const verifyScript = resolve(repoRoot, 'apps/swift/scripts/verify-testflight-replacement.ts'); + +describe('upload-testflight CLI', () => { + it('uses BUILD_NUMBER in dry-run preflight output', () => { + const output = execFileSync('bun', [script, '--replacement', '--production', '--dry-run'], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...currentEnv, APPLE_ASC_PROVIDER: 'PackRatProvider', BUILD_NUMBER: '2026071801' }, + }); + + const preflight = JSON.parse(output); + expect(preflight).toMatchObject({ + lane: 'replacement', + bundleId: 'com.andrewbierman.packrat', + displayName: 'PackRat', + marketingVersion: monorepoVersion, + buildNumber: '2026071801', + apiEnvironment: 'production', + ascProvider: 'PackRatProvider', + }); + expect(preflight.archiveOverrides).toContain(`MARKETING_VERSION=${monorepoVersion}`); + expect(preflight.archiveOverrides).toContain('CURRENT_PROJECT_VERSION=2026071801'); + }); + + it('verifies replacement TestFlight readiness from the CLI', () => { + const output = execFileSync('bun', [verifyScript, '--replacement', '--production'], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...currentEnv, + BUILD_NUMBER: '2026071802', + APP_STORE_CURRENT_BUILD_NUMBER: '2026071801', + }, + }); + + const report = JSON.parse(output); + expect(report).toMatchObject({ + lane: 'replacement', + bundleId: 'com.andrewbierman.packrat', + displayName: 'PackRat', + apiEnvironment: 'production', + marketingVersion: monorepoVersion, + buildNumber: '2026071802', + currentAppStoreBuildNumber: '2026071801', + ok: true, + errors: [], + }); + }); + + it('fails replacement preflight CLI without the current App Store build number', () => { + const env = { ...currentEnv, BUILD_NUMBER: '2026071802' }; + delete env.APP_STORE_CURRENT_BUILD_NUMBER; + + const result = spawnSync('bun', [verifyScript, '--replacement', '--production'], { + cwd: repoRoot, + encoding: 'utf8', + env, + }); + + expect(result.status).toBe(1); + const report = JSON.parse(result.stdout); + expect(report.ok).toBe(false); + expect(report.errors).toContain( + 'APP_STORE_CURRENT_BUILD_NUMBER was not provided; verify the replacement build number is greater than the latest App Store Connect build before upload.', + ); + }); + + it('blocks real replacement uploads before Apple credentials when current build is missing', () => { + const env = { + ...currentEnv, + APPLE_ID: 'tester@example.com', + APPLE_APP_PASSWORD: 'test-app-password', + APPLE_TEAM_ID: 'TEAM123', + BUILD_NUMBER: '2026071802', + }; + delete env.APP_STORE_CURRENT_BUILD_NUMBER; + + const result = spawnSync('bun', [script, '--replacement', '--production'], { + cwd: repoRoot, + encoding: 'utf8', + env, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + 'Replacement TestFlight preflight failed: APP_STORE_CURRENT_BUILD_NUMBER was not provided', + ); + expect(result.stderr).not.toContain('xcodebuild archive'); + }); +}); diff --git a/apps/swift/scripts/__tests__/verify-deployed-auth.test.ts b/apps/swift/scripts/__tests__/verify-deployed-auth.test.ts new file mode 100644 index 0000000000..de6a2e2d57 --- /dev/null +++ b/apps/swift/scripts/__tests__/verify-deployed-auth.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest'; +import { verifyDeployedAuth } from '../verify-deployed-auth'; + +function response(input: { + ok: boolean; + status?: number; + body?: unknown; + tokenHeader?: string; + jsonThrows?: boolean; +}) { + return { + ok: input.ok, + status: input.status ?? (input.ok ? 200 : 401), + headers: new Headers(input.tokenHeader ? { 'set-auth-token': input.tokenHeader } : {}), + json: async () => { + if (input.jsonThrows) throw new Error('not json'); + return input.body ?? {}; + }, + }; +} + +describe('verifyDeployedAuth', () => { + it('posts Better Auth credentials to the deployed API', async () => { + const fetchImpl = vi.fn(async () => + response({ + ok: true, + body: { user: { id: 'user-1' } }, + tokenHeader: 'session-token', + }), + ); + + await verifyDeployedAuth({ + apiBaseURL: 'https://api.example.test/base-path', + email: 'tester@example.com', + password: 'correct horse battery staple', + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledOnce(); + const [url, init] = fetchImpl.mock.calls[0]; + expect(String(url)).toBe('https://api.example.test/base-path/api/auth/sign-in/email'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toMatchObject({ + 'Content-Type': 'application/json', + Origin: 'packrat://', + }); + expect(JSON.parse(String(init?.body))).toEqual({ + email: 'tester@example.com', + password: 'correct horse battery staple', + }); + }); + + it('accepts a token in the JSON body when the auth header is absent', async () => { + await expect( + verifyDeployedAuth({ + apiBaseURL: 'https://api.example.test', + email: 'tester@example.com', + password: 'password', + fetchImpl: async () => + response({ ok: true, body: { user: { id: 'user-1' }, token: 'body-token' } }), + }), + ).resolves.toBeUndefined(); + }); + + it('reports the Better Auth message for bad credentials', async () => { + await expect( + verifyDeployedAuth({ + apiBaseURL: 'https://api.example.test', + email: 'tester@example.com', + password: 'wrong', + fetchImpl: async () => + response({ + ok: false, + status: 401, + body: { message: 'Invalid email or password', code: 'INVALID_EMAIL_OR_PASSWORD' }, + }), + }), + ).rejects.toThrow( + 'Swift deployed auth preflight failed: Invalid email or password. Check that E2E_TEST_EMAIL/E2E_TEST_PASSWORD match a real QA user on https://api.example.test; production is not seeded by Swift CI.', + ); + }); + + it('falls back to status when the error body is not JSON', async () => { + await expect( + verifyDeployedAuth({ + apiBaseURL: 'https://api.example.test', + email: 'tester@example.com', + password: 'wrong', + fetchImpl: async () => response({ ok: false, status: 503, jsonThrows: true }), + }), + ).rejects.toThrow('Swift deployed auth preflight failed: HTTP 503'); + }); + + it('fails when the deployed response lacks a user or session token', async () => { + await expect( + verifyDeployedAuth({ + apiBaseURL: 'https://api.example.test', + email: 'tester@example.com', + password: 'password', + fetchImpl: async () => response({ ok: true, body: { user: { id: 'user-1' } } }), + }), + ).rejects.toThrow('Swift deployed auth preflight succeeded without user or session token'); + }); + + it('fails before network calls when inputs are missing', async () => { + const fetchImpl = vi.fn(); + + await expect( + verifyDeployedAuth({ + apiBaseURL: 'https://api.example.test', + email: '', + password: 'password', + fetchImpl, + }), + ).rejects.toThrow('Missing deployed auth preflight input: E2E_EMAIL'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/swift/scripts/capture-visual-screenshots.ts b/apps/swift/scripts/capture-visual-screenshots.ts index 68ac432dc1..475a8ad2c8 100644 --- a/apps/swift/scripts/capture-visual-screenshots.ts +++ b/apps/swift/scripts/capture-visual-screenshots.ts @@ -1,14 +1,18 @@ #!/usr/bin/env bun import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { cpSync, existsSync, mkdirSync, + mkdtempSync, readdirSync, readFileSync, rmSync, + statSync, writeFileSync, } from 'node:fs'; +import { tmpdir } from 'node:os'; import { basename, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { APP_CONFIG } from '@packrat/config/config'; @@ -26,6 +30,7 @@ import { oneOrMore, } from 'magic-regexp'; import { z } from 'zod'; +import { ensureLocalE2EAPI } from './lib/e2e-api'; import { formatSummaryLine, readSummary, type TestSummary, XcResultError } from './lib/xcresult'; type Platform = 'ios' | 'ipad' | 'macos' | 'watch'; @@ -56,6 +61,7 @@ type VisualTestResult = { type PlatformRunSummary = { platform: Platform; screenshotDir: string; + screenshotCount: number; coverageManifest: string; contactSheet: string; groupedContactSheets: string[]; @@ -67,6 +73,15 @@ const REPO_ROOT = resolve(import.meta.dir, '../../..'); const SWIFT_DIR = resolve(REPO_ROOT, 'apps/swift'); const RESULTS_DIR = resolve(SWIFT_DIR, 'TestResults'); const DEFAULT_OUT_DIR = resolve(REPO_ROOT, 'artifacts/screenshots'); +const IOS_SCHEME_PATH = resolve( + SWIFT_DIR, + 'PackRat.xcodeproj/xcshareddata/xcschemes/PackRat-iOS.xcscheme', +); +const MACOS_SCHEME_PATH = resolve( + SWIFT_DIR, + 'PackRat.xcodeproj/xcshareddata/xcschemes/PackRat-macOS.xcscheme', +); +const WATCH_BUNDLE_ID = nodeEnv.PACKRAT_WATCH_BUNDLE_ID ?? 'com.andrewbierman.packrat.watchkitapp'; const EMAIL_RE = createRegExp( oneOrMore(charIn('A-Z0-9._%+-')), '@', @@ -82,6 +97,7 @@ const SECRET_BUILD_SETTING_RE = createRegExp( oneOrMore(charNotIn(' \t\n\r')), [globalFlag], ); +const E2E_LOCAL_TOKEN_RE = /e2e-local\.[A-Fa-f0-9]+/g; const XCODEBUILD_TIMEOUT_MS = durationFromEnv('PACKRAT_VISUAL_XCODEBUILD_TIMEOUT_MS', 30 * 60_000); const XCRESULT_EXPORT_TIMEOUT_MS = durationFromEnv('PACKRAT_XCRESULT_EXPORT_TIMEOUT_MS', 90_000); const AUTOMATION_MODE_TIMEOUT_MS = 10_000; @@ -98,6 +114,7 @@ const CHROME_CANDIDATES = [ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', ]; const FEATURE_FLAGS = APP_CONFIG.featureFlags; +const API_E2E_ENV_PATH = resolve(REPO_ROOT, 'packages/api/.dev.vars.e2e'); const IOS_SURFACES = [ 'packs', 'trips', @@ -246,7 +263,7 @@ Captures guest and authenticated visual surfaces through VisualScreenshotTests a } function parseArgs(argv: readonly string[]): Options { - let platforms: Platform[] = ['ios', 'ipad', 'macos']; + let platforms: Platform[] = ['ios', 'ipad', 'macos', 'watch']; let outDir = DEFAULT_OUT_DIR; let skipTests = false; @@ -260,7 +277,7 @@ function parseArgs(argv: readonly string[]): Options { } if (arg === '--platform') { const value = argv[++i]; - if (!value) throw new Error('--platform requires ios, ipad, macos, or both'); + if (!value) throw new Error('--platform requires ios, ipad, macos, watch, both, or all'); platforms = parsePlatforms(value); continue; } @@ -286,7 +303,7 @@ function parseArgs(argv: readonly string[]): Options { function parsePlatforms(value: string): Platform[] { const normalized = value.toLowerCase(); - if (normalized === 'both') return ['ios', 'ipad', 'macos']; + if (normalized === 'both') return ['ios', 'ipad', 'macos', 'watch']; if (normalized === 'all') return ['ios', 'ipad', 'macos', 'watch']; if (normalized === 'ios') return ['ios']; if (normalized === 'ipad') return ['ipad']; @@ -334,6 +351,7 @@ function redactSecrets(output: string): string { if (!secret) continue; redacted = redacted.split(secret).join('[REDACTED]'); } + redacted = redacted.replace(E2E_LOCAL_TOKEN_RE, '[REDACTED_E2E_TOKEN]'); redacted = redacted.replace(EMAIL_RE, '[REDACTED_EMAIL]'); redacted = redacted.replace(SECRET_BUILD_SETTING_RE, (match) => { const equalsIndex = match.indexOf('='); @@ -741,9 +759,26 @@ function loadEnvFile(envFile: string): void { function pickIOSDestination(platform: Extract): string { if (platform === 'ipad') { return pickAvailableIOSDestination({ - preferredNames: ['iPad Pro 13-inch (M5)', 'iPad Pro 11-inch (M5)', 'iPad Air 13-inch (M4)'], + preferredNames: [ + 'PackRat E2E iPad', + 'iPad Pro 13-inch (M5)', + 'iPad Pro 11-inch (M5)', + 'iPad Air 13-inch (M4)', + 'iPad Air 13-inch (M2)', + 'iPad Pro (12.9-inch) (6th generation)', + ], fallbackName: 'iPad Pro 13-inch (M5)', nameIncludes: 'iPad', + createIfMissing: { + name: 'PackRat E2E iPad', + preferredDeviceTypes: [ + 'iPad Pro 13-inch (M5)', + 'iPad Pro 11-inch (M5)', + 'iPad Air 13-inch (M4)', + 'iPad Air 13-inch (M2)', + 'iPad Pro (12.9-inch) (6th generation)', + ], + }, }); } return pickAvailableIOSDestination({ @@ -757,21 +792,25 @@ function pickAvailableIOSDestination({ preferredNames, fallbackName, nameIncludes, + createIfMissing, }: { preferredNames: string[]; fallbackName: string; nameIncludes: string; + createIfMissing?: { name: string; preferredDeviceTypes: string[] }; }): string { const result = spawnSync('xcrun', ['simctl', 'list', 'devices', 'available', '-j'], { encoding: 'utf8', timeout: 10_000, maxBuffer: 10 * 1024 * 1024, }); + let inventorySucceeded = false; if (result.status === 0) { try { const parsed = safeJsonParse<{ devices?: Record>; }>(result.stdout, { strict: true }); + inventorySucceeded = true; const availableDevices = Object.values(parsed.devices ?? {}).flat(); for (const preferredName of preferredNames) { const preferred = availableDevices.find( @@ -787,9 +826,91 @@ function pickAvailableIOSDestination({ } } catch {} } + if (createIfMissing && inventorySucceeded) { + const createdDeviceId = createIOSSimulator(createIfMissing); + if (createdDeviceId) return `platform=iOS Simulator,id=${createdDeviceId}`; + } return `platform=iOS Simulator,name=${fallbackName}`; } +function createIOSSimulator({ + name, + preferredDeviceTypes, +}: { + name: string; + preferredDeviceTypes: string[]; +}): string | null { + const deviceTypeId = pickDeviceTypeId(preferredDeviceTypes); + const runtimeId = pickLatestIOSRuntimeId(); + if (!deviceTypeId || !runtimeId) return null; + const result = spawnSync('xcrun', ['simctl', 'create', name, deviceTypeId, runtimeId], { + encoding: 'utf8', + timeout: 30_000, + }); + if (result.status !== 0) return null; + const deviceId = result.stdout.trim(); + return deviceId || null; +} + +function pickDeviceTypeId(preferredNames: string[]): string | null { + const result = spawnSync('xcrun', ['simctl', 'list', 'devicetypes', '-j'], { + encoding: 'utf8', + timeout: 10_000, + maxBuffer: 10 * 1024 * 1024, + }); + if (result.status !== 0) return null; + try { + const parsed = safeJsonParse<{ + devicetypes?: Array<{ name?: string; identifier?: string }>; + }>(result.stdout, { strict: true }); + for (const preferredName of preferredNames) { + const deviceType = parsed.devicetypes?.find((candidate) => candidate.name === preferredName); + if (deviceType?.identifier) return deviceType.identifier; + } + } catch {} + return null; +} + +function pickLatestIOSRuntimeId(): string | null { + const result = spawnSync('xcrun', ['simctl', 'list', 'runtimes', '-j'], { + encoding: 'utf8', + timeout: 10_000, + maxBuffer: 10 * 1024 * 1024, + }); + if (result.status !== 0) return null; + try { + const parsed = safeJsonParse<{ + runtimes?: Array<{ + identifier?: string; + isAvailable?: boolean; + platform?: string; + version?: string; + }>; + }>(result.stdout, { strict: true }); + const runtimes = (parsed.runtimes ?? []) + .filter( + (runtime) => + runtime.isAvailable && + runtime.identifier && + (runtime.platform === 'iOS' || runtime.identifier.includes('iOS')), + ) + .sort((a, b) => compareVersions(b.version ?? '', a.version ?? '')); + return runtimes[0]?.identifier ?? null; + } catch {} + return null; +} + +function compareVersions(left: string, right: string): number { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + const count = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < count; index += 1) { + const diff = (leftParts[index] || 0) - (rightParts[index] || 0); + if (diff !== 0) return diff; + } + return 0; +} + function allocateResultBundle(platform: Platform): string { if (!existsSync(RESULTS_DIR)) mkdirSync(RESULTS_DIR, { recursive: true }); const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-'); @@ -800,12 +921,32 @@ function allocateResultBundle(platform: Platform): string { return path; } -function runXcodeVisualTest(platform: Platform, screenshotDir: string): Promise { +async function runXcodeVisualTest( + platform: Platform, + screenshotDir: string, +): Promise { if (platform === 'watch') return runWatchVisualCapture(screenshotDir); const resultBundle = allocateResultBundle(platform); const writableScreenshotDir = allocateWritableScreenshotDir(platform); - const credentials = e2eBuildSettings(); + const packratEnv = Bun.env.PACKRAT_ENV ?? nodeEnv.PACKRAT_ENV ?? 'local'; + const authMode = visualAuthMode(packratEnv); + const apiBaseURL = Bun.env.E2E_API_BASE_URL ?? ''; + const credentials = e2eBuildSettings(packratEnv); + const visualBuildSettings = [ + ...(apiBaseURL ? [`E2E_API_BASE_URL=${apiBaseURL}`] : []), + `PACKRAT_ENV=${packratEnv}`, + `PACKRAT_VISUAL_AUTH_MODE=${authMode}`, + `PACKRAT_VISUAL_PLATFORM=${platform}`, + ]; + const restoreVisualScheme = injectVisualSchemeEnvironment(platform, { + E2E_API_BASE_URL: apiBaseURL, + PACKRAT_ENV: packratEnv, + PACKRAT_SCREENSHOT_DIR: writableScreenshotDir, + PACKRAT_VISUAL_AUTH_MODE: authMode, + PACKRAT_VISUAL_PLATFORM: platform, + ...buildSettingsToEnv(credentials), + }); const commonArgs = [ 'test', '-resultBundlePath', @@ -821,6 +962,7 @@ function runXcodeVisualTest(platform: Platform, screenshotDir: string): Promise< '-destination', pickIOSDestination(platform), '-only-testing:PackRatUITests/VisualScreenshotTests', + ...visualBuildSettings, ...credentials, ] : [ @@ -835,6 +977,7 @@ function runXcodeVisualTest(platform: Platform, screenshotDir: string): Promise< 'CODE_SIGN_IDENTITY=-', 'CODE_SIGNING_ALLOWED=YES', 'CODE_SIGNING_REQUIRED=NO', + ...visualBuildSettings, ...credentials, ]; @@ -843,71 +986,164 @@ function runXcodeVisualTest(platform: Platform, screenshotDir: string): Promise< console.log(`→ XCTest write dir: ${writableScreenshotDir}`); console.log(`→ Result bundle: ${resultBundle}`); - if (platform === 'macos') assertAutomationModeAvailable(); + try { + await assertDeployedVisualAuthReady({ packratEnv, apiBaseURL, authMode }); + + if (platform === 'macos') { + assertAutomationModeAvailable(); + clearMacNotificationBanners(); + } - return new Promise((resolvePromise, reject) => { - let timedOut = false; - let finalized = false; - const child = spawn('xcodebuild', args, { - cwd: SWIFT_DIR, - env: { - ...Bun.env, - PACKRAT_ENV: Bun.env.PACKRAT_ENV ?? nodeEnv.PACKRAT_ENV ?? 'local', - PACKRAT_SCREENSHOT_DIR: writableScreenshotDir, - PACKRAT_VISUAL_PLATFORM: platform, - }, - }); - const timeout = setTimeout(() => { - timedOut = true; - console.error( - `xcodebuild timed out after ${Math.round(XCODEBUILD_TIMEOUT_MS / 1000)}s for ${platform}; terminating child process.`, - ); - child.kill('SIGINT'); - setTimeout(() => { - if (!child.killed) child.kill('SIGKILL'); - }, 5_000).unref(); - }, XCODEBUILD_TIMEOUT_MS); - timeout.unref(); - - child.stdout.on('data', (chunk) => process.stdout.write(redactSecrets(chunk.toString()))); - child.stderr.on('data', (chunk) => process.stderr.write(redactSecrets(chunk.toString()))); - child.on('error', (err) => { - if (finalized) return; - finalized = true; - clearTimeout(timeout); - reject(err); - }); - const finalize = (code: number | null) => { - if (finalized) return; - finalized = true; - clearTimeout(timeout); - try { - const summary = summarizeResult(resultBundle); - copyScreenshots(writableScreenshotDir, screenshotDir); - if (listScreenshots(screenshotDir).length === 0) { - exportScreenshotsFromResultBundle(resultBundle, screenshotDir); - } - if (code === 0) { - resolvePromise({ resultBundle, summary }); + return await new Promise((resolvePromise, reject) => { + let timedOut = false; + let finalized = false; + const child = spawn('xcodebuild', args, { + cwd: SWIFT_DIR, + env: { + ...Bun.env, + E2E_API_BASE_URL: apiBaseURL, + PACKRAT_ENV: packratEnv, + PACKRAT_VISUAL_AUTH_MODE: authMode, + PACKRAT_SCREENSHOT_DIR: writableScreenshotDir, + PACKRAT_VISUAL_PLATFORM: platform, + }, + }); + const timeout = setTimeout(() => { + timedOut = true; + console.error( + `xcodebuild timed out after ${Math.round(XCODEBUILD_TIMEOUT_MS / 1000)}s for ${platform}; terminating child process.`, + ); + child.kill('SIGINT'); + setTimeout(() => { + if (!child.killed) child.kill('SIGKILL'); + }, 5_000).unref(); + }, XCODEBUILD_TIMEOUT_MS); + timeout.unref(); + + child.stdout.on('data', (chunk) => process.stdout.write(redactSecrets(chunk.toString()))); + child.stderr.on('data', (chunk) => process.stderr.write(redactSecrets(chunk.toString()))); + child.on('error', (err) => { + if (finalized) return; + finalized = true; + clearTimeout(timeout); + reject(err); + }); + const finalize = (code: number | null) => { + if (finalized) return; + finalized = true; + clearTimeout(timeout); + try { + const summary = summarizeResult(resultBundle); + copyScreenshots(writableScreenshotDir, screenshotDir); + if (listScreenshots(screenshotDir).length === 0) { + exportScreenshotsFromResultBundle(resultBundle, screenshotDir); + } + if (code === 0) { + resolvePromise({ resultBundle, summary }); + return; + } + } catch (err) { + reject(err); return; } - } catch (err) { - reject(err); - return; - } - if (timedOut) { - reject( - new Error( - `xcodebuild timed out after ${Math.round(XCODEBUILD_TIMEOUT_MS / 1000)}s for ${platform}`, - ), - ); - } else { - reject(new Error(`xcodebuild exited with ${code ?? 'unknown status'} for ${platform}`)); - } - }; - child.on('exit', finalize); - child.on('close', finalize); - }); + if (timedOut) { + reject( + new Error( + `xcodebuild timed out after ${Math.round(XCODEBUILD_TIMEOUT_MS / 1000)}s for ${platform}`, + ), + ); + } else { + reject(new Error(`xcodebuild exited with ${code ?? 'unknown status'} for ${platform}`)); + } + }; + child.on('exit', finalize); + child.on('close', finalize); + }); + } finally { + restoreVisualScheme(); + } +} + +function injectVisualSchemeEnvironment( + platform: Platform, + env: Record, +): () => void { + const schemePath = platform === 'macos' ? MACOS_SCHEME_PATH : IOS_SCHEME_PATH; + if (!existsSync(schemePath)) return () => {}; + + const originalContent = readFileSync(schemePath, 'utf8'); + let content = originalContent; + const restore = () => { + if (existsSync(schemePath) && readFileSync(schemePath, 'utf8') !== originalContent) { + writeFileSync(schemePath, originalContent); + } + }; + content = removeEnvironmentVariablesBlock(content); + content = content.replace( + 'shouldUseLaunchSchemeArgsEnv = "YES"', + 'shouldUseLaunchSchemeArgsEnv = "NO"', + ); + + const variables = Object.entries(env) + .filter(([, value]) => value.length > 0) + .map(([key, value]) => environmentVariableXml(key, value)); + if (variables.length === 0) { + writeFileSync(schemePath, content); + return restore; + } + + const block = [ + ' ', + ...variables, + ' ', + '', + ].join('\n'); + writeFileSync(schemePath, content.replace(' ', `${block} `)); + return restore; +} + +function buildSettingsToEnv(settings: string[]): Record { + const env: Record = {}; + for (const setting of settings) { + const equals = setting.indexOf('='); + if (equals <= 0) continue; + env[setting.slice(0, equals)] = setting.slice(equals + 1); + } + return env; +} + +function environmentVariableXml(key: string, value: string): string { + return [ + ' ', + ' ', + ].join('\n'); +} + +function removeEnvironmentVariablesBlock(content: string): string { + let output = content; + while (true) { + const start = output.indexOf(''); + if (start === -1) return output; + const end = output.indexOf('', start); + if (end === -1) return output; + const removalStart = output.lastIndexOf('\n', start); + const removalEnd = end + ''.length; + output = `${output.slice(0, removalStart === -1 ? start : removalStart)}${output.slice(removalEnd)}`; + } +} + +function escapeXml(s: string): string { + return Array.from(s, (char) => { + if (char === '&') return '&'; + if (char === '<') return '<'; + if (char === '>') return '>'; + if (char === '"') return '"'; + if (char === "'") return '''; + return char; + }).join(''); } async function runWatchVisualCapture(screenshotDir: string): Promise { @@ -952,11 +1188,11 @@ async function runWatchVisualCapture(screenshotDir: string): Promise { + const { packratEnv, apiBaseURL, authMode } = input; + if (authMode !== 'real') return; + + const baseURL = + apiBaseURL || + (packratEnv === 'dev' + ? 'https://packrat-api-dev.orange-frost-d665.workers.dev' + : packratEnv === 'production' + ? 'https://packrat-api.orange-frost-d665.workers.dev' + : ''); + if (!baseURL) return; + + const email = Bun.env.E2E_TEST_EMAIL ?? Bun.env.E2E_EMAIL ?? apiE2EEnv.E2E_TEST_EMAIL; + const password = Bun.env.E2E_TEST_PASSWORD ?? Bun.env.E2E_PASSWORD ?? apiE2EEnv.E2E_TEST_PASSWORD; + if (!email || !password) { + throw new Error( + `PACKRAT_VISUAL_AUTH_MODE=real for PACKRAT_ENV=${packratEnv}, but E2E credentials are missing.`, + ); + } + + const response = await fetch(`${baseURL}/api/auth/sign-in/email`, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: safeJsonStringify({ email, password }), + }); + if (!response.ok) { + throw new Error( + [ + `Swift visual real-auth preflight failed for PACKRAT_ENV=${packratEnv}: ${response.status}.`, + packratEnv === 'dev' + ? 'Seed the dev E2E user before capturing deployed-dev screenshots.' + : 'Use a valid production QA account before capturing production/TestFlight screenshots.', + 'The visual runner no longer falls back to seeded auth for deployed APIs.', + ].join(' '), + ); + } + console.log(`✓ Verified deployed ${packratEnv} E2E auth before visual capture`); +} + +function localE2ESessionToken(input: { secret: string; email: string; userId: string }): string { + const material = `${input.secret}:${input.email.toLowerCase()}:${input.userId}`; + return `e2e-local.${createHash('sha256').update(material).digest('hex')}`; +} + +function readSimpleEnvFile(path: string): Record { + if (!existsSync(path)) return {}; + const vars: Record = {}; + for (const rawLine of readFileSync(path, 'utf8').replaceAll('\r\n', '\n').split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || !line.includes('=')) continue; + const index = line.indexOf('='); + const key = line.slice(0, index).trim(); + let value = line.slice(index + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + vars[key] = value; + } + return vars; } function assertAutomationModeAvailable(): void { @@ -1490,11 +1847,8 @@ async function screenshotHtml({ outputPath: string; platform: Platform; }): Promise { - const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate)); - if (chrome) { - renderWithSystemChrome({ chrome, htmlPath, images, outputPath, platform }); - return; - } + if (platform === 'macos') clearMacNotificationBanners(); + rmSync(outputPath, { force: true }); try { const { chromium } = await import('@playwright/test'); @@ -1511,13 +1865,17 @@ async function screenshotHtml({ await browser.close(); } } catch (err) { - throw new Error( - `No contact sheet renderer found. System Chrome is unavailable and Playwright failed: ${formatError(err)}`, - ); + const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate)); + if (chrome) { + await renderWithSystemChrome({ chrome, htmlPath, images, outputPath, platform }); + return; + } + + throw new Error(`No contact sheet renderer found. Playwright failed: ${formatError(err)}`); } } -function renderWithSystemChrome({ +async function renderWithSystemChrome({ chrome, htmlPath, images, @@ -1529,29 +1887,137 @@ function renderWithSystemChrome({ images: string[]; outputPath: string; platform: Platform; -}): void { +}): Promise { const width = platform === 'ios' ? 1600 : 1800; const height = estimateContactSheetHeight({ images, platform, width }); - const result = spawnSync( + const userDataDir = mkdtempSync(resolve(tmpdir(), 'packrat-contact-sheet-chrome-')); + const child = spawn( chrome, [ '--headless=new', '--disable-gpu', + '--disable-background-networking', + '--disable-breakpad', + '--disable-crash-reporter', + '--disable-extensions', + '--disable-notifications', + '--deny-permission-prompts', '--hide-scrollbars', + '--no-default-browser-check', + '--no-first-run', + `--user-data-dir=${userDataDir}`, `--window-size=${width},${height}`, `--screenshot=${outputPath}`, pathToFileURL(htmlPath).href, ], - { encoding: 'utf8', timeout: CONTACT_SHEET_RENDER_TIMEOUT_MS }, + { detached: true, stdio: ['ignore', 'pipe', 'pipe'] }, ); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); - if (result.status !== 0) { - throw new Error( - `Chrome screenshot failed: ${result.stderr || result.stdout || `exit ${result.status}`}`, - ); + const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolveExit, rejectExit) => { + child.once('error', rejectExit); + child.once('exit', (code, signal) => resolveExit({ code, signal })); + }, + ); + + try { + const startedAt = Date.now(); + while (Date.now() - startedAt < CONTACT_SHEET_RENDER_TIMEOUT_MS) { + if (fileIsNonEmpty(outputPath)) { + const exitResult = await Promise.race([exitPromise, sleep(1_000).then(() => null)]); + if (exitResult?.code === 0) return; + await stopProcessGroup({ pid: child.pid, exitPromise, signal: 'SIGKILL' }); + return; + } + + const exitResult = await Promise.race([exitPromise, sleep(250).then(() => null)]); + if (exitResult) { + if (exitResult.code === 0 && fileIsNonEmpty(outputPath)) return; + throw new Error( + `Chrome screenshot failed: ${stderr || stdout || `exit ${exitResult.code ?? exitResult.signal}`}`, + ); + } + } + + await stopProcessGroup({ pid: child.pid, exitPromise, signal: 'SIGKILL' }); + throw new Error(`Chrome screenshot timed out after ${CONTACT_SHEET_RENDER_TIMEOUT_MS}ms`); + } finally { + rmSync(userDataDir, { recursive: true, force: true }); + } +} + +function fileIsNonEmpty(filePath: string): boolean { + try { + return statSync(filePath).size > 0; + } catch { + return false; } } +async function stopProcessGroup({ + pid, + exitPromise, + signal = 'SIGTERM', +}: { + pid: number | undefined; + exitPromise: Promise; + signal?: NodeJS.Signals; +}): Promise { + if (!pid) return; + + try { + process.kill(-pid, signal); + } catch { + try { + process.kill(pid, signal); + } catch { + return; + } + } + + await Promise.race([ + exitPromise.catch(() => undefined), + sleep(signal === 'SIGKILL' ? 500 : 1_000), + ]); +} + +function clearMacNotificationBanners(): void { + if (process.platform !== 'darwin') return; + spawnSync( + 'osascript', + [ + '-e', + `tell application "System Events" + tell process "NotificationCenter" + repeat with notificationWindow in windows + repeat with candidate in entire contents of notificationWindow + try + if subrole of candidate is "AXNotificationCenterAlert" then + repeat with candidateAction in actions of candidate + try + if name of candidateAction is "Close" then perform candidateAction + end try + end repeat + end if + end try + end repeat + end repeat + end tell +end tell`, + ], + { encoding: 'utf8', timeout: 5_000 }, + ); + spawnSync('killall', ['NotificationCenter'], { encoding: 'utf8', timeout: 5_000 }); +} + function estimateContactSheetHeight({ images, platform, @@ -1563,21 +2029,24 @@ function estimateContactSheetHeight({ }): number { const horizontalPadding = 64; const gridGap = 18; - const cardWidth = platform === 'watch' ? 220 : platform === 'ios' ? 300 : 520; + const cardWidth = platform === 'watch' ? 220 : platform === 'macos' ? 520 : 300; const columns = Math.max( 1, Math.floor((width - horizontalPadding + gridGap) / (cardWidth + gridGap)), ); + const renderedCardWidth = Math.floor( + (width - horizontalPadding - gridGap * (columns - 1)) / columns, + ); const cardHeights = images.map((image) => { const size = readImageSize(image); if (!size) return platform === 'watch' ? 280 : platform === 'ios' ? 720 : 420; - return Math.ceil((size.height / size.width) * cardWidth) + 42; + return Math.ceil((size.height / size.width) * renderedCardWidth) + 64; }); const rows: number[] = []; for (let index = 0; index < cardHeights.length; index += columns) { rows.push(Math.max(...cardHeights.slice(index, index + columns))); } - return Math.max(1200, 116 + rows.reduce((sum, row) => sum + row, 0) + gridGap * rows.length); + return Math.max(1200, 160 + rows.reduce((sum, row) => sum + row, 0) + gridGap * rows.length); } function readImageSize(image: string): { width: number; height: number } | null { @@ -1658,49 +2127,70 @@ async function main() { loadDotEnv(); const options = parseArgs(process.argv.slice(2)); mkdirSync(options.outDir, { recursive: true }); - const runSummary: PlatformRunSummary[] = []; - - for (const platform of options.platforms) { - const dir = screenshotDirFor(options.outDir, platform); - let testResult: VisualTestResult | null = null; - mkdirSync(dir, { recursive: true }); - if (!options.skipTests) { - rmSync(dir, { recursive: true, force: true }); + const runSummaryPath = resolve(options.outDir, 'run-summary.json'); + const runSummaryByPlatform = readExistingRunSummary(runSummaryPath); + addExistingArtifactSummaries(options.outDir, runSummaryByPlatform); + const shouldEnsureAPI = + !options.skipTests && options.platforms.some((platform) => platform !== 'watch'); + const apiHandle = shouldEnsureAPI + ? await ensureLocalE2EAPI({ + packratEnv: Bun.env.PACKRAT_ENV ?? nodeEnv.PACKRAT_ENV ?? 'local', + env: Bun.env as NodeJS.ProcessEnv, + }) + : null; + + try { + for (const platform of options.platforms) { + const dir = screenshotDirFor(options.outDir, platform); + let testResult: VisualTestResult | null = null; mkdirSync(dir, { recursive: true }); - testResult = await runXcodeVisualTest(platform, dir); - } - validateScreenshotMatrix(platform, dir); - const contactSheet = await renderContactSheet(platform, options.outDir); - const groupedContactSheets = await renderGroupedContactSheets(platform, options.outDir); - const coverageManifest = resolve(dir, 'coverage-manifest.json'); - runSummary.push({ - platform, - screenshotDir: dir, - coverageManifest, - contactSheet, - groupedContactSheets, - ...(testResult - ? { - resultBundle: testResult.resultBundle, - ...(testResult.summary ? { testSummary: testResult.summary } : {}), - } - : {}), - }); - console.log(`✓ ${platform} contact sheet: ${contactSheet}`); - for (const groupedContactSheet of groupedContactSheets) { - console.log(`✓ ${platform} grouped contact sheet: ${groupedContactSheet}`); + if (!options.skipTests) { + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + testResult = await runXcodeVisualTest(platform, dir); + } + validateScreenshotMatrix(platform, dir); + const contactSheet = await renderContactSheet(platform, options.outDir); + const groupedContactSheets = await renderGroupedContactSheets(platform, options.outDir); + const coverageManifest = resolve(dir, 'coverage-manifest.json'); + const screenshotCount = countCapturedScreenshots(dir); + const existingSummary = runSummaryByPlatform.get(platform); + runSummaryByPlatform.set(platform, { + platform, + screenshotDir: dir, + screenshotCount, + coverageManifest, + contactSheet, + groupedContactSheets, + ...(testResult + ? { + resultBundle: testResult.resultBundle, + ...(testResult.summary ? { testSummary: testResult.summary } : {}), + } + : { + ...(existingSummary?.resultBundle + ? { resultBundle: existingSummary.resultBundle } + : {}), + ...(existingSummary?.testSummary ? { testSummary: existingSummary.testSummary } : {}), + }), + }); + console.log(`✓ ${platform} contact sheet: ${contactSheet}`); + for (const groupedContactSheet of groupedContactSheets) { + console.log(`✓ ${platform} grouped contact sheet: ${groupedContactSheet}`); + } + console.log(`✓ ${platform} coverage manifest: ${coverageManifest}`); } - console.log(`✓ ${platform} coverage manifest: ${coverageManifest}`); + } finally { + await apiHandle?.stop(); } - const runSummaryPath = resolve(options.outDir, 'run-summary.json'); writeFileSync( runSummaryPath, `${safeJsonStringify( { generatedAt: new Date().toISOString(), skipTests: options.skipTests, - platforms: runSummary, + platforms: [...runSummaryByPlatform.values()], }, null, 2, @@ -1709,6 +2199,69 @@ async function main() { console.log(`✓ screenshot run summary: ${runSummaryPath}`); } +function readExistingRunSummary(path: string): Map { + const summaries = new Map(); + if (!existsSync(path)) return summaries; + try { + const parsed = safeJsonParse<{ platforms?: unknown[] }>(readFileSync(path, 'utf8'), { + strict: true, + }); + for (const candidate of parsed.platforms ?? []) { + const summary = parsePlatformRunSummary(candidate); + if (summary) summaries.set(summary.platform, summary); + } + } catch {} + return summaries; +} + +function addExistingArtifactSummaries( + outDir: string, + summaries: Map, +): void { + for (const platform of ['ios', 'ipad', 'macos', 'watch'] satisfies Platform[]) { + if (summaries.has(platform)) continue; + const screenshotDir = screenshotDirFor(outDir, platform); + const coverageManifest = resolve(screenshotDir, 'coverage-manifest.json'); + const contactSheet = resolve(outDir, `${platform}-contact-sheet.png`); + if (!existsSync(coverageManifest) || !existsSync(contactSheet)) continue; + const groupedContactSheets = CONTACT_SHEET_GROUPS.map((group) => + resolve(outDir, `${platform}-contact-sheet-${group.suffix}.png`), + ).filter((path) => existsSync(path)); + summaries.set(platform, { + platform, + screenshotDir, + screenshotCount: countCapturedScreenshots(screenshotDir), + coverageManifest, + contactSheet, + groupedContactSheets, + }); + } +} + +function parsePlatformRunSummary(value: unknown): PlatformRunSummary | null { + const candidate = parsePlatformRunSummaryValue(value); + if (!candidate) return null; + + return { + platform: candidate.platform, + screenshotDir: candidate.screenshotDir, + screenshotCount: + candidate.screenshotCount !== undefined && Number.isFinite(candidate.screenshotCount) + ? candidate.screenshotCount + : countCapturedScreenshots(candidate.screenshotDir), + coverageManifest: candidate.coverageManifest, + contactSheet: candidate.contactSheet, + groupedContactSheets: candidate.groupedContactSheets, + ...(candidate.resultBundle ? { resultBundle: candidate.resultBundle } : {}), + ...(candidate.testSummary ? { testSummary: candidate.testSummary } : {}), + }; +} + +function countCapturedScreenshots(screenshotDir: string): number { + if (!existsSync(screenshotDir)) return 0; + return readdirSync(screenshotDir).filter((fileName) => fileName.endsWith('.png')).length; +} + main() .then(() => process.exit(0)) .catch((err) => { diff --git a/apps/swift/scripts/generate-swift-config.ts b/apps/swift/scripts/generate-swift-config.ts index b8e3ae7844..4ed8c363ee 100644 --- a/apps/swift/scripts/generate-swift-config.ts +++ b/apps/swift/scripts/generate-swift-config.ts @@ -15,6 +15,9 @@ import { writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { APP_CONFIG } from '@packrat/config/config'; +import { nodeEnv } from '@packrat/env/node'; +import { isObject } from '@packrat/guards'; +import { safeJsonParse } from '@packrat/utils'; import { renderSwiftFeatureFlags } from './lib/config-codegen'; const __dir = dirname(fileURLToPath(import.meta.url)); @@ -31,10 +34,73 @@ const outputs = [ }, ]; +type FeatureFlags = typeof APP_CONFIG.featureFlags; +type FeatureFlagName = keyof FeatureFlags; + +function isFeatureFlagName(value: string): value is FeatureFlagName { + return Object.hasOwn(APP_CONFIG.featureFlags, value); +} + +function featureFlagsWithValue(value: boolean): FeatureFlags { + const flags = { ...APP_CONFIG.featureFlags }; + for (const key of Object.keys(flags)) { + if (isFeatureFlagName(key)) flags[key] = value; + } + return flags; +} + +function parseFeatureFlagOverrides(): Partial> { + const raw = nodeEnv.PACKRAT_SWIFT_FEATURE_FLAG_OVERRIDES; + if (!raw?.trim()) return {}; + + const parsed = safeJsonParse(raw); + if (!isObject(parsed)) { + throw new Error('PACKRAT_SWIFT_FEATURE_FLAG_OVERRIDES must be a JSON object'); + } + const overrides: Partial> = {}; + + for (const [key, value] of Object.entries(parsed)) { + if (!isFeatureFlagName(key)) { + throw new Error(`Unknown PACKRAT_SWIFT_FEATURE_FLAG_OVERRIDES key: ${key}`); + } + if (value !== true && value !== false) { + throw new Error(`PACKRAT_SWIFT_FEATURE_FLAG_OVERRIDES.${key} must be a boolean`); + } + overrides[key] = value; + } + + return overrides; +} + +function featureFlagsForProfile(): FeatureFlags { + const profile = nodeEnv.PACKRAT_SWIFT_FEATURE_FLAG_PROFILE ?? 'default'; + const baseFlags = + profile === 'default' + ? { ...APP_CONFIG.featureFlags } + : profile === 'all-on' + ? featureFlagsWithValue(true) + : profile === 'all-off' + ? featureFlagsWithValue(false) + : undefined; + + if (!baseFlags) { + throw new Error( + `Unsupported PACKRAT_SWIFT_FEATURE_FLAG_PROFILE "${profile}". Use default, all-on, or all-off.`, + ); + } + + return { + ...baseFlags, + ...parseFeatureFlagOverrides(), + }; +} + +const featureFlags = featureFlagsForProfile(); + for (const output of outputs) { const rendered = renderSwiftFeatureFlags({ enumName: output.enumName, - featureFlags: APP_CONFIG.featureFlags, + featureFlags, sourceDescription, }); writeFileSync(output.path, rendered, 'utf8'); diff --git a/apps/swift/scripts/lib/app-store-assets.ts b/apps/swift/scripts/lib/app-store-assets.ts index ae4399f925..6c42c224be 100644 --- a/apps/swift/scripts/lib/app-store-assets.ts +++ b/apps/swift/scripts/lib/app-store-assets.ts @@ -39,7 +39,7 @@ export type ImageInspector = (path: string) => ImageInfo; const PIXEL_WIDTH_RE = /pixelWidth:\s*([0-9.]+)/; const PIXEL_HEIGHT_RE = /pixelHeight:\s*([0-9.]+)/; const HAS_ALPHA_RE = /hasAlpha:\s*(yes|no)/; -const ICON_SIZE_RE = /^(\d+)x(\d+)$/; +const ICON_SIZE_RE = /^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/; const ICON_SCALE_RE = /^(\d+)x$/; export function parseSipsImageInfo(output: string): ImageInfo { diff --git a/apps/swift/scripts/lib/args.ts b/apps/swift/scripts/lib/args.ts index 10da458055..4b6e226ad1 100644 --- a/apps/swift/scripts/lib/args.ts +++ b/apps/swift/scripts/lib/args.ts @@ -1,17 +1,25 @@ -export type TestPlanName = 'iOS-Full' | 'iOS-Smoke'; +export type TestPlanName = 'iOS-Full' | 'iOS-Smoke' | 'iOS-Sanity'; export type ParsedArgs = { plan?: TestPlanName; passthrough: string[]; }; -const KNOWN_PLANS: TestPlanName[] = ['iOS-Full', 'iOS-Smoke']; +const KNOWN_PLANS: TestPlanName[] = ['iOS-Full', 'iOS-Smoke', 'iOS-Sanity']; const ALIASES: Record = { full: 'iOS-Full', smoke: 'iOS-Smoke', + sanity: 'iOS-Sanity', + 'ios-ui': 'iOS-Full', 'ios-full': 'iOS-Full', 'ios-smoke': 'iOS-Smoke', + 'ios-sanity': 'iOS-Sanity', +}; + +const POSITIONAL_MODES: Record = { + unit: { passthrough: ['-only-testing:PackRatTests'] }, + 'ios-unit': { passthrough: ['-only-testing:PackRatTests'] }, }; export class ArgsError extends Error { @@ -45,7 +53,9 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { if (a === '--plan') { const next = argv[i + 1]; if (!next || next.startsWith('-')) { - throw new ArgsError('--plan requires a value (smoke | full | iOS-Smoke | iOS-Full)'); + throw new ArgsError( + '--plan requires a value (sanity | smoke | full | iOS-Sanity | iOS-Smoke | iOS-Full)', + ); } plan = resolvePlan(next); i++; @@ -55,6 +65,17 @@ export function parseArgs(argv: readonly string[]): ParsedArgs { plan = resolvePlan(a.slice('--plan='.length)); continue; } + const positionalPlan = ALIASES[a.toLowerCase()]; + if (positionalPlan) { + plan = positionalPlan; + continue; + } + const positionalMode = POSITIONAL_MODES[a.toLowerCase()]; + if (positionalMode) { + if (positionalMode.plan) plan = positionalMode.plan; + passthrough.push(...positionalMode.passthrough); + continue; + } passthrough.push(a); } return { plan, passthrough }; diff --git a/apps/swift/scripts/lib/e2e-api.ts b/apps/swift/scripts/lib/e2e-api.ts new file mode 100644 index 0000000000..a118d133a0 --- /dev/null +++ b/apps/swift/scripts/lib/e2e-api.ts @@ -0,0 +1,122 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { resolve } from 'node:path'; + +type LocalAPIHandle = { + baseURL: string; + stop: () => Promise; +}; + +const REPO_ROOT = resolve(import.meta.dir, '../../../..'); +const API_DIR = resolve(REPO_ROOT, 'packages/api'); + +function timeoutSignal(ms: number): AbortSignal { + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms).unref(); + return controller.signal; +} + +async function isHealthy(baseURL: string): Promise { + try { + const response = await fetch(new URL('/health', baseURL), { + signal: timeoutSignal(1000), + }); + return response.ok; + } catch { + return false; + } +} + +async function waitForHealthy(input: { baseURL: string; child: ChildProcess }): Promise { + const { baseURL, child } = input; + const startedAt = Date.now(); + let exited = false; + let exitCode: number | null = null; + let spawnError: Error | undefined; + child.once('error', (error) => { + spawnError = error; + }); + child.once('exit', (code) => { + exited = true; + exitCode = code; + }); + + while (Date.now() - startedAt < 120_000) { + if (spawnError) { + throw new Error(`Local E2E API failed to start: ${spawnError.message}`); + } + if (await isHealthy(baseURL)) return; + if (exited) { + throw new Error( + `Local E2E API exited before becoming healthy (code ${exitCode ?? 'unknown'}).`, + ); + } + await Bun.sleep(1000); + } + + throw new Error(`Local E2E API did not become healthy at ${baseURL}/health within 120s.`); +} + +async function stopChild(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + resolve(); + }, 5000); + timeout.unref(); + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + +export async function ensureLocalE2EAPI(input: { + packratEnv: string; + env: NodeJS.ProcessEnv; +}): Promise { + const explicitBaseURL = input.env.E2E_API_BASE_URL; + const defaultPort = input.packratEnv === 'dev-local' ? '8791' : input.env.PORT || '8787'; + const baseURL = explicitBaseURL?.trim() || `http://localhost:${defaultPort}`; + const shouldUseLocalAPI = input.packratEnv === 'local' || input.packratEnv === 'dev-local'; + const skipStart = input.env.PACKRAT_SWIFT_E2E_SKIP_API_START === '1'; + + if (!shouldUseLocalAPI) { + return { baseURL, stop: async () => {} }; + } + + if (await isHealthy(baseURL)) { + console.log(`✓ Local E2E API is healthy at ${baseURL}`); + return { baseURL, stop: async () => {} }; + } + + if (explicitBaseURL || skipStart) { + throw new Error( + `Local E2E API is not healthy at ${baseURL}/health. Start it with \`bun run --cwd packages/api dev:e2e\` or unset E2E_API_BASE_URL so the runner can own it.`, + ); + } + + console.log(`→ Starting local E2E API at ${baseURL}`); + const child = spawn('bun', ['run', 'dev:e2e'], { + cwd: API_DIR, + env: input.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout?.on('data', (chunk) => process.stdout.write(chunk)); + child.stderr?.on('data', (chunk) => process.stderr.write(chunk)); + + try { + await waitForHealthy({ baseURL, child }); + console.log(`✓ Local E2E API is healthy at ${baseURL}`); + } catch (error) { + await stopChild(child); + throw error; + } + + return { + baseURL, + stop: () => stopChild(child), + }; +} diff --git a/apps/swift/scripts/lib/testflight-binary.ts b/apps/swift/scripts/lib/testflight-binary.ts new file mode 100644 index 0000000000..5acaf4a211 --- /dev/null +++ b/apps/swift/scripts/lib/testflight-binary.ts @@ -0,0 +1,189 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { isString } from '@packrat/guards'; +import { safeJsonParse } from '@packrat/utils'; +import type { TestFlightUploadConfig } from './testflight-config'; + +type PlistValue = string | boolean | number | null; +type Plist = Record; + +export type TestFlightBinaryVerification = { + ok: boolean; + errors: string[]; + iosApp: string | null; + watchApp: string | null; +}; + +function xmlUnescape(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('>', '>') + .replaceAll('<', '<') + .replaceAll('&', '&'); +} + +function parseXmlPlistStrings(xml: string): Plist { + const result: Plist = {}; + const pattern = + /([^<]+)<\/key>\s*(?:([\s\S]*?)<\/string>|([\s\S]*?)<\/integer>|<(true|false)\/>)/g; + for (const match of xml.matchAll(pattern)) { + const key = xmlUnescape(match[1]); + if (match[2] !== undefined) result[key] = xmlUnescape(match[2]); + else if (match[3] !== undefined) result[key] = Number(match[3]); + else result[key] = match[4] === 'true'; + } + return result; +} + +function readPlist(path: string): Plist { + try { + return safeJsonParse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', path], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }), + { strict: true }, + ); + } catch { + return parseXmlPlistStrings(readFileSync(path, 'utf8')); + } +} + +function findAppBundles(root: string): string[] { + if (!existsSync(root)) return []; + const entries = readdirSync(root).map((entry) => join(root, entry)); + const apps: string[] = []; + for (const entry of entries) { + const stats = statSync(entry); + if (!stats.isDirectory()) continue; + if (entry.endsWith('.app') && existsSync(join(entry, 'Info.plist'))) { + apps.push(entry); + continue; + } + apps.push(...findAppBundles(entry)); + } + return apps; +} + +function plistString(input: { plist: Plist; key: string }): string { + const { plist, key } = input; + const value = plist[key]; + return isString(value) ? value : String(value ?? ''); +} + +function expectEqual(input: { errors: string[]; label: string; actual: string; expected: string }) { + const { errors, label, actual, expected } = input; + if (actual !== expected) + errors.push(`${label}: expected ${expected}, got ${actual || ''}.`); +} + +function verifyAppBundles(input: { + root: string; + config: TestFlightUploadConfig; +}): TestFlightBinaryVerification { + const { root, config } = input; + const errors: string[] = []; + const apps = findAppBundles(root); + const iosApp = + apps.find( + (app) => !app.includes('/Watch/') && basename(app).toLowerCase().includes('packrat'), + ) ?? + apps.find((app) => !app.includes('/Watch/')) ?? + null; + + if (!iosApp) { + return { + ok: false, + errors: ['Could not find iOS .app bundle in archive/export.'], + iosApp, + watchApp: null, + }; + } + + const iosPlist = readPlist(join(iosApp, 'Info.plist')); + expectEqual({ + errors, + label: 'iOS bundle id', + actual: plistString({ plist: iosPlist, key: 'CFBundleIdentifier' }), + expected: config.bundleId, + }); + expectEqual({ + errors, + label: 'iOS display name', + actual: plistString({ plist: iosPlist, key: 'CFBundleDisplayName' }), + expected: config.displayName, + }); + expectEqual({ + errors, + label: 'iOS build number', + actual: plistString({ plist: iosPlist, key: 'CFBundleVersion' }), + expected: config.buildNumber, + }); + expectEqual({ + errors, + label: 'iOS API environment', + actual: plistString({ plist: iosPlist, key: 'PACKRAT_ENV' }), + expected: config.apiEnvironment, + }); + + const watchApp = findAppBundles(join(iosApp, 'Watch')).at(0) ?? null; + if (!watchApp) { + errors.push('Could not find embedded watchOS .app bundle.'); + } else { + const watchPlist = readPlist(join(watchApp, 'Info.plist')); + expectEqual({ + errors, + label: 'watchOS bundle id', + actual: plistString({ plist: watchPlist, key: 'CFBundleIdentifier' }), + expected: config.watchBundleId, + }); + expectEqual({ + errors, + label: 'watchOS companion bundle id', + actual: plistString({ plist: watchPlist, key: 'WKCompanionAppBundleIdentifier' }), + expected: config.companionBundleId, + }); + expectEqual({ + errors, + label: 'watchOS display name', + actual: plistString({ plist: watchPlist, key: 'CFBundleDisplayName' }), + expected: config.displayName, + }); + expectEqual({ + errors, + label: 'watchOS build number', + actual: plistString({ plist: watchPlist, key: 'CFBundleVersion' }), + expected: config.buildNumber, + }); + } + + return { ok: errors.length === 0, errors, iosApp, watchApp }; +} + +export function verifyTestFlightArchive(input: { + archivePath: string; + config: TestFlightUploadConfig; +}): TestFlightBinaryVerification { + return verifyAppBundles({ + root: join(input.archivePath, 'Products', 'Applications'), + config: input.config, + }); +} + +export function verifyTestFlightIPA(input: { + ipaPath: string; + config: TestFlightUploadConfig; +}): TestFlightBinaryVerification { + const work = mkdtempSync(join(tmpdir(), 'packrat-ipa-verify-')); + try { + execFileSync('unzip', ['-q', input.ipaPath, '-d', work], { + stdio: ['ignore', 'ignore', 'pipe'], + }); + return verifyAppBundles({ root: join(work, 'Payload'), config: input.config }); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} diff --git a/apps/swift/scripts/lib/testflight-config.ts b/apps/swift/scripts/lib/testflight-config.ts new file mode 100644 index 0000000000..7e77327238 --- /dev/null +++ b/apps/swift/scripts/lib/testflight-config.ts @@ -0,0 +1,199 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { isString, toRecord } from '@packrat/guards'; +import { safeJsonParse, safeJsonStringify } from '@packrat/utils'; + +export type TestFlightLane = 'side-by-side' | 'replacement'; + +export type TestFlightUploadConfig = { + lane: TestFlightLane; + staging: boolean; + dryRun: boolean; + scheme: string; + configuration: string; + bundleId: string; + watchBundleId: string; + companionBundleId: string; + displayName: string; + marketingVersion: string; + buildNumber: string; + apiEnvironment: 'dev' | 'production'; +}; + +export type TestFlightReplacementReadinessInput = { + config: TestFlightUploadConfig; + currentAppStoreBuildNumber?: string | undefined; + requireCurrentAppStoreBuildNumber?: boolean | undefined; +}; + +export type TestFlightReplacementReadiness = { + ok: boolean; + errors: string[]; + warnings: string[]; +}; + +export class TestFlightConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'TestFlightConfigError'; + } +} + +const SIDE_BY_SIDE_BUNDLE_ID = 'com.andrewbierman.packrat.swift'; +const REPLACEMENT_BUNDLE_ID = 'com.andrewbierman.packrat'; +const SIDE_BY_SIDE_WATCH_BUNDLE_ID = 'com.andrewbierman.packrat.swift.watchkitapp'; +const REPLACEMENT_WATCH_BUNDLE_ID = 'com.andrewbierman.packrat.watchkitapp'; +/** + * The Swift marketing version tracks the monorepo version in the root + * `package.json`, which `bun bump` owns. Reading it here instead of hardcoding a + * constant keeps this script, `apps/swift/project.yml`, and the monorepo version + * from drifting apart across releases. + */ +function readMonorepoVersion(): string { + const rootPackageJsonPath = join(__dirname, '../../../../package.json'); + // strict: a malformed root package.json must fail loudly here rather than + // fall through to the version check as an unparsed string. + const parsed = toRecord( + safeJsonParse(readFileSync(rootPackageJsonPath, 'utf-8'), { strict: true }), + ); + if (!('version' in parsed)) { + throw new TestFlightConfigError( + `Root package.json at ${rootPackageJsonPath} has no "version" field; cannot resolve the Swift marketing version.`, + ); + } + const { version } = parsed; + if (!isString(version) || version.trim() === '') { + throw new TestFlightConfigError( + `Root package.json "version" must be a non-empty string, got ${safeJsonStringify(parsed.version)}.`, + ); + } + return version; +} + +export function parseTestFlightUploadConfig(input: { + argv: readonly string[]; + env?: { BUILD_NUMBER?: string | undefined; MARKETING_VERSION?: string | undefined }; +}): TestFlightUploadConfig { + const { argv, env = {} } = input; + const sideBySide = argv.includes('--side-by-side'); + const replacement = argv.includes('--replacement'); + const staging = argv.includes('--staging'); + const production = argv.includes('--production'); + const dryRun = argv.includes('--dry-run'); + + if (sideBySide === replacement) { + throw new TestFlightConfigError( + 'Choose exactly one TestFlight lane: --replacement for the existing Expo/App Store listing, or --side-by-side for the separate Swift beta app.', + ); + } + if (staging && production) { + throw new TestFlightConfigError('Use either --staging or --production, not both.'); + } + + const lane: TestFlightLane = replacement ? 'replacement' : 'side-by-side'; + const marketingVersion = env.MARKETING_VERSION ?? readMonorepoVersion(); + const buildNumber = env.BUILD_NUMBER ?? String(Math.floor(Date.now() / 1000)); + + return { + lane, + staging, + dryRun, + scheme: staging ? 'PackRat-iOS-Staging' : 'PackRat-iOS', + configuration: staging ? 'Staging' : 'Release', + bundleId: replacement ? REPLACEMENT_BUNDLE_ID : SIDE_BY_SIDE_BUNDLE_ID, + watchBundleId: replacement ? REPLACEMENT_WATCH_BUNDLE_ID : SIDE_BY_SIDE_WATCH_BUNDLE_ID, + companionBundleId: replacement ? REPLACEMENT_BUNDLE_ID : SIDE_BY_SIDE_BUNDLE_ID, + displayName: replacement ? 'PackRat' : 'PackRat Swift', + marketingVersion, + buildNumber, + apiEnvironment: staging ? 'dev' : 'production', + }; +} + +export function xcodeArchiveOverrides(input: { + config: TestFlightUploadConfig; + teamId: string; +}): string[] { + const { config, teamId } = input; + return [ + `MARKETING_VERSION=${config.marketingVersion}`, + `CURRENT_PROJECT_VERSION=${config.buildNumber}`, + `DEVELOPMENT_TEAM=${teamId}`, + `PACKRAT_IOS_BUNDLE_IDENTIFIER=${config.bundleId}`, + `PACKRAT_WATCH_BUNDLE_IDENTIFIER=${config.watchBundleId}`, + `PACKRAT_COMPANION_BUNDLE_IDENTIFIER=${config.companionBundleId}`, + `PACKRAT_DISPLAY_NAME=${config.displayName}`, + ]; +} + +function isPositiveInteger(value: string): boolean { + const numeric = Number(value); + return Number.isSafeInteger(numeric) && numeric > 0 && String(numeric) === value; +} + +export function verifyTestFlightReplacementReadiness( + input: TestFlightReplacementReadinessInput, +): TestFlightReplacementReadiness { + const { config, currentAppStoreBuildNumber, requireCurrentAppStoreBuildNumber = false } = input; + const errors: string[] = []; + const warnings: string[] = []; + + if (config.lane !== 'replacement') { + errors.push('Use --replacement. Side-by-side Swift beta builds cannot update the Expo app.'); + } + if ( + config.staging || + config.configuration !== 'Release' || + config.apiEnvironment !== 'production' + ) { + errors.push('Use the production Release archive for a seamless TestFlight update.'); + } + if (config.bundleId !== REPLACEMENT_BUNDLE_ID) { + errors.push(`Expected iOS bundle id ${REPLACEMENT_BUNDLE_ID}, got ${config.bundleId}.`); + } + if (config.watchBundleId !== REPLACEMENT_WATCH_BUNDLE_ID) { + errors.push( + `Expected watch bundle id ${REPLACEMENT_WATCH_BUNDLE_ID}, got ${config.watchBundleId}.`, + ); + } + if (config.companionBundleId !== REPLACEMENT_BUNDLE_ID) { + errors.push( + `Expected watch companion bundle id ${REPLACEMENT_BUNDLE_ID}, got ${config.companionBundleId}.`, + ); + } + if (config.displayName !== 'PackRat') { + errors.push(`Expected display name PackRat, got ${config.displayName}.`); + } + if (!isPositiveInteger(config.buildNumber)) { + errors.push(`Build number must be a positive integer, got ${config.buildNumber}.`); + } + + if (currentAppStoreBuildNumber) { + if (!isPositiveInteger(currentAppStoreBuildNumber)) { + errors.push( + `Current App Store build number must be a positive integer, got ${currentAppStoreBuildNumber}.`, + ); + } else if ( + isPositiveInteger(config.buildNumber) && + Number(config.buildNumber) <= Number(currentAppStoreBuildNumber) + ) { + errors.push( + `Build number ${config.buildNumber} must be greater than current App Store/TestFlight build ${currentAppStoreBuildNumber}.`, + ); + } + } else { + const message = + 'APP_STORE_CURRENT_BUILD_NUMBER was not provided; verify the replacement build number is greater than the latest App Store Connect build before upload.'; + if (requireCurrentAppStoreBuildNumber) { + errors.push(message); + } else { + warnings.push(message); + } + } + + return { + ok: errors.length === 0, + errors, + warnings, + }; +} diff --git a/apps/swift/scripts/lib/testflight-export.ts b/apps/swift/scripts/lib/testflight-export.ts new file mode 100644 index 0000000000..0940957419 --- /dev/null +++ b/apps/swift/scripts/lib/testflight-export.ts @@ -0,0 +1,26 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +export class TestFlightExportError extends Error { + constructor(message: string) { + super(message); + this.name = 'TestFlightExportError'; + } +} + +export function findExportedIPA(exportDir: string): string { + const ipaFiles = readdirSync(exportDir) + .filter((file) => file.endsWith('.ipa')) + .sort(); + + if (ipaFiles.length === 0) { + throw new TestFlightExportError(`No .ipa file found in ${exportDir}.`); + } + if (ipaFiles.length > 1) { + throw new TestFlightExportError( + `Expected one .ipa file in ${exportDir}, found: ${ipaFiles.join(', ')}.`, + ); + } + + return join(exportDir, ipaFiles[0]); +} diff --git a/apps/swift/scripts/run-e2e-macos.ts b/apps/swift/scripts/run-e2e-macos.ts index 8598ce489b..03b7314da5 100644 --- a/apps/swift/scripts/run-e2e-macos.ts +++ b/apps/swift/scripts/run-e2e-macos.ts @@ -22,7 +22,7 @@ import { createHash } from 'node:crypto'; * - Scheme is PackRat-macOS, destination is platform=macOS. * - Different test-plan name space (macOS-Smoke / macOS-Full instead of iOS-*). */ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; import { resolve } from 'node:path'; import { anyOf, @@ -34,6 +34,7 @@ import { oneOrMore, } from 'magic-regexp'; import { ArgsError } from './lib/args'; +import { ensureLocalE2EAPI } from './lib/e2e-api'; import { normalizeMacOSTestSelectors, parseMacOSArgs } from './lib/macos-args'; import { formatSummaryLine, readSummary, XcResultError } from './lib/xcresult'; @@ -87,15 +88,20 @@ if (!E2E_EMAIL || !E2E_PASSWORD) { process.exit(1); } const PACKRAT_ENV = process.env.PACKRAT_ENV || 'local'; -const localE2ESessionToken = deriveLocalE2ESessionToken(); -const uiTestEmail = process.env.E2E_TEST_EMAIL ?? E2E_EMAIL; -const uiTestPassword = process.env.E2E_TEST_PASSWORD ?? E2E_PASSWORD; if (!existsSync(SCHEME_PATH)) { console.error(`❌ Scheme not found at ${SCHEME_PATH} — run 'bun swift' first`); process.exit(1); } +const localAPI = await ensureLocalE2EAPI({ packratEnv: PACKRAT_ENV, env: process.env }); +loadEnvFile(resolve(REPO_ROOT, 'packages/api/.dev.vars.e2e'), true); + +const localE2ESessionToken = deriveLocalE2ESessionToken(); +const allowLoginSeed = PACKRAT_ENV === 'local' || PACKRAT_ENV === 'dev-local'; +const uiTestEmail = process.env.E2E_TEST_EMAIL ?? E2E_EMAIL; +const uiTestPassword = process.env.E2E_TEST_PASSWORD ?? E2E_PASSWORD; + function assertAutomationModeAvailable(): void { const result = spawnSync('automationmodetool', ['help'], { encoding: 'utf8', @@ -116,85 +122,16 @@ function assertAutomationModeAvailable(): void { } } -function escapeXml(s: string): string { - return Array.from(s, (char) => { - if (char === '&') return '&'; - if (char === '<') return '<'; - if (char === '>') return '>'; - if (char === '"') return '"'; - if (char === "'") return '''; - return char; - }).join(''); -} - function deriveLocalE2ESessionToken(): string | undefined { - const dbUrl = process.env.NEON_DATABASE_URL ?? ''; - const secret = process.env.BETTER_AUTH_SECRET; + if (PACKRAT_ENV !== 'local' && PACKRAT_ENV !== 'dev-local') return undefined; + const secret = process.env.BETTER_AUTH_SECRET ?? 'e2e-better-auth-secret-at-least-32-chars'; const email = process.env.E2E_TEST_EMAIL?.toLowerCase(); const userId = process.env.E2E_TEST_USER_ID; - if (!(dbUrl.includes('127.0.0.1') || dbUrl.includes('localhost'))) return undefined; - if (!secret || !email || !userId) return undefined; + if (!email || !userId) return undefined; const digest = createHash('sha256').update([secret, email, userId].join(':')).digest('hex'); return `e2e-local.${digest}`; } -type SchemeEnv = { - email: string; - password: string; - sessionToken?: string; - userId?: string; -}; - -function environmentVariableXml(key: string, value: string): string { - return [ - ' ', - ' ', - ].join('\n'); -} - -function injectScheme({ email, password, sessionToken, userId }: SchemeEnv): void { - let content = readFileSync(SCHEME_PATH, 'utf8'); - content = removeEnvironmentVariablesBlock(content); - content = content.replace( - 'shouldUseLaunchSchemeArgsEnv = "YES"', - 'shouldUseLaunchSchemeArgsEnv = "NO"', - ); - const variables = [ - environmentVariableXml('E2E_EMAIL', email), - environmentVariableXml('E2E_PASSWORD', password), - environmentVariableXml('PACKRAT_E2E_EMAIL', uiTestEmail), - environmentVariableXml('PACKRAT_E2E_PASSWORD', uiTestPassword), - ]; - if (sessionToken) - variables.push(environmentVariableXml('PACKRAT_E2E_SESSION_TOKEN', sessionToken)); - if (userId) variables.push(environmentVariableXml('PACKRAT_E2E_USER_ID', userId)); - - const block = [ - ' ', - ...variables, - ' ', - '', - ].join('\n'); - content = content.replace(' ', `${block} `); - writeFileSync(SCHEME_PATH, content); -} - -function removeEnvironmentVariablesBlock(content: string): string { - let output = content; - while (true) { - const start = output.indexOf(''); - if (start === -1) return output; - const end = output.indexOf('', start); - if (end === -1) return output; - const removalStart = output.lastIndexOf('\n', start); - const removalEnd = end + ''.length; - output = `${output.slice(0, removalStart === -1 ? start : removalStart)}${output.slice(removalEnd)}`; - } -} - function allocateResultBundle(): string { if (!existsSync(RESULTS_DIR)) mkdirSync(RESULTS_DIR, { recursive: true }); const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-'); @@ -229,13 +166,6 @@ try { throw err; } -injectScheme({ - email: E2E_EMAIL, - password: E2E_PASSWORD, - sessionToken: localE2ESessionToken, - userId: process.env.E2E_TEST_USER_ID, -}); -console.log('✓ Injected E2E credentials into PackRat-macOS scheme'); assertAutomationModeAvailable(); const resultBundle = allocateResultBundle(); @@ -262,6 +192,7 @@ const args = [ `PACKRAT_E2E_PASSWORD=${uiTestPassword}`, `PACKRAT_E2E_SESSION_TOKEN=${localE2ESessionToken ?? ''}`, `PACKRAT_E2E_USER_ID=${process.env.E2E_TEST_USER_ID ?? ''}`, + `PACKRAT_E2E_ALLOW_LOGIN_SEED=${allowLoginSeed ? '1' : '0'}`, `PACKRAT_ENV=${PACKRAT_ENV}`, ]; @@ -284,41 +215,44 @@ function redactSecrets(output: string): string { return redacted; } -const resultStatus = await new Promise((resolve) => { - const child = spawn('xcodebuild', args, { - cwd: SWIFT_DIR, - env: process.env, - }); +let exitStatus = 1; +try { + const resultStatus = await new Promise((resolve, reject) => { + const child = spawn('xcodebuild', args, { + cwd: SWIFT_DIR, + env: process.env, + }); - child.stdout.on('data', (chunk) => { - process.stdout.write(redactSecrets(chunk.toString())); - }); - child.stderr.on('data', (chunk) => { - process.stderr.write(redactSecrets(chunk.toString())); + child.stdout.on('data', (chunk) => { + process.stdout.write(redactSecrets(chunk.toString())); + }); + child.stderr.on('data', (chunk) => { + process.stderr.write(redactSecrets(chunk.toString())); + }); + child.once('error', reject); + child.on('close', (code) => resolve(code)); }); - child.on('close', (code) => resolve(code)); -}); + exitStatus = resultStatus ?? 1; -const result = { - status: resultStatus, -}; - -try { - const summary = readSummary(resultBundle); - console.log(''); - console.log(formatSummaryLine(summary)); - if (summary.failingTests.length > 0) { - console.log(' Failing tests:'); - for (const t of summary.failingTests) { - console.log(` • ${t.identifier}`); + try { + const summary = readSummary(resultBundle); + console.log(''); + console.log(formatSummaryLine(summary)); + if (summary.failingTests.length > 0) { + console.log(' Failing tests:'); + for (const t of summary.failingTests) { + console.log(` • ${t.identifier}`); + } + } + } catch (err) { + if (err instanceof XcResultError) { + console.error(`⚠️ ${err.message}`); + } else { + throw err; } } -} catch (err) { - if (err instanceof XcResultError) { - console.error(`⚠️ ${err.message}`); - } else { - throw err; - } +} finally { + await localAPI.stop(); } -process.exit(result.status ?? 1); +process.exit(exitStatus); diff --git a/apps/swift/scripts/run-e2e.ts b/apps/swift/scripts/run-e2e.ts index 94e6d11499..602e1a7716 100644 --- a/apps/swift/scripts/run-e2e.ts +++ b/apps/swift/scripts/run-e2e.ts @@ -13,14 +13,11 @@ import { createHash } from 'node:crypto'; * E2E_EMAIL * E2E_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. + * Credentials reach the test runner through xcodebuild build-setting overrides, + * which populate the UITests bundle Info.plist keys declared in project.yml. + * The app receives the same values through launchEnvironment in AppUITestCase. */ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; import { resolve } from 'node:path'; import { anyOf, @@ -32,6 +29,7 @@ import { oneOrMore, } from 'magic-regexp'; import { ArgsError, parseArgs } from './lib/args'; +import { ensureLocalE2EAPI } from './lib/e2e-api'; import { listBootedIOS } from './lib/simctl'; import { formatSummaryLine, readSummary, XcResultError } from './lib/xcresult'; @@ -87,104 +85,30 @@ if (!E2E_EMAIL || !E2E_PASSWORD) { process.exit(1); } const PACKRAT_ENV = process.env.PACKRAT_ENV || 'local'; -const localE2ESessionToken = deriveLocalE2ESessionToken(); -const uiTestEmail = process.env.E2E_TEST_EMAIL ?? E2E_EMAIL; -const uiTestPassword = process.env.E2E_TEST_PASSWORD ?? E2E_PASSWORD; if (!existsSync(SCHEME_PATH)) { console.error(`❌ Scheme not found at ${SCHEME_PATH} — run 'bun swift' first`); process.exit(1); } -// ── Inject credentials into scheme ─────────────────────────────────────────── +const localAPI = await ensureLocalE2EAPI({ packratEnv: PACKRAT_ENV, env: process.env }); +loadEnvFile(resolve(REPO_ROOT, 'packages/api/.dev.vars.e2e'), true); -function escapeXml(s: string): string { - return Array.from(s, (char) => { - if (char === '&') return '&'; - if (char === '<') return '<'; - if (char === '>') return '>'; - if (char === '"') return '"'; - if (char === "'") return '''; - return char; - }).join(''); -} +const localE2ESessionToken = deriveLocalE2ESessionToken(); +const allowLoginSeed = PACKRAT_ENV === 'local' || PACKRAT_ENV === 'dev-local'; +const uiTestEmail = process.env.E2E_TEST_EMAIL ?? E2E_EMAIL; +const uiTestPassword = process.env.E2E_TEST_PASSWORD ?? E2E_PASSWORD; function deriveLocalE2ESessionToken(): string | undefined { - const dbUrl = process.env.NEON_DATABASE_URL ?? ''; - const secret = process.env.BETTER_AUTH_SECRET; + if (PACKRAT_ENV !== 'local' && PACKRAT_ENV !== 'dev-local') return undefined; + const secret = process.env.BETTER_AUTH_SECRET ?? 'e2e-better-auth-secret-at-least-32-chars'; const email = process.env.E2E_TEST_EMAIL?.toLowerCase(); const userId = process.env.E2E_TEST_USER_ID; - if (!(dbUrl.includes('127.0.0.1') || dbUrl.includes('localhost'))) return undefined; - if (!secret || !email || !userId) return undefined; + if (!email || !userId) return undefined; const digest = createHash('sha256').update([secret, email, userId].join(':')).digest('hex'); return `e2e-local.${digest}`; } -type SchemeEnv = { - email: string; - password: string; - sessionToken?: string; - userId?: string; -}; - -function environmentVariableXml(key: string, value: string): string { - return [ - ' ', - ' ', - ].join('\n'); -} - -function injectScheme({ email, password, sessionToken, userId }: SchemeEnv): void { - let content = readFileSync(SCHEME_PATH, 'utf8'); - - // Strip any prior EnvironmentVariables block (idempotent re-runs). - content = removeEnvironmentVariablesBlock(content); - - // Force TestAction to use its own env vars rather than inheriting from Run. - content = content.replace( - 'shouldUseLaunchSchemeArgsEnv = "YES"', - 'shouldUseLaunchSchemeArgsEnv = "NO"', - ); - - const variables = [ - environmentVariableXml('E2E_EMAIL', email), - environmentVariableXml('E2E_PASSWORD', password), - environmentVariableXml('PACKRAT_E2E_EMAIL', uiTestEmail), - environmentVariableXml('PACKRAT_E2E_PASSWORD', uiTestPassword), - ]; - if (sessionToken) - variables.push(environmentVariableXml('PACKRAT_E2E_SESSION_TOKEN', sessionToken)); - if (userId) variables.push(environmentVariableXml('PACKRAT_E2E_USER_ID', userId)); - - const block = [ - ' ', - ...variables, - ' ', - '', - ].join('\n'); - - // Insert before . - content = content.replace(' ', `${block} `); - - writeFileSync(SCHEME_PATH, content); -} - -function removeEnvironmentVariablesBlock(content: string): string { - let output = content; - while (true) { - const start = output.indexOf(''); - if (start === -1) return output; - const end = output.indexOf('', start); - if (end === -1) return output; - const removalStart = output.lastIndexOf('\n', start); - const removalEnd = end + ''.length; - output = `${output.slice(0, removalStart === -1 ? start : removalStart)}${output.slice(removalEnd)}`; - } -} - // ── Pick destination ───────────────────────────────────────────────────────── function pickDestination(): string { @@ -221,14 +145,6 @@ try { // ── Run xcodebuild ─────────────────────────────────────────────────────────── -injectScheme({ - email: E2E_EMAIL, - password: E2E_PASSWORD, - sessionToken: localE2ESessionToken, - userId: process.env.E2E_TEST_USER_ID, -}); -console.log('✓ Injected E2E credentials into scheme'); - const dest = pickDestination(); const resultBundle = allocateResultBundle(); console.log(`→ Destination: ${dest}`); @@ -256,6 +172,7 @@ const args = [ `PACKRAT_E2E_PASSWORD=${uiTestPassword}`, `PACKRAT_E2E_SESSION_TOKEN=${localE2ESessionToken ?? ''}`, `PACKRAT_E2E_USER_ID=${process.env.E2E_TEST_USER_ID ?? ''}`, + `PACKRAT_E2E_ALLOW_LOGIN_SEED=${allowLoginSeed ? '1' : '0'}`, `PACKRAT_ENV=${PACKRAT_ENV}`, ]; @@ -278,43 +195,46 @@ function redactSecrets(output: string): string { return redacted; } -const resultStatus = await new Promise((resolve) => { - const child = spawn('xcodebuild', args, { - cwd: SWIFT_DIR, - env: process.env, - }); - - child.stdout.on('data', (chunk) => { - process.stdout.write(redactSecrets(chunk.toString())); - }); - child.stderr.on('data', (chunk) => { - process.stderr.write(redactSecrets(chunk.toString())); +let exitStatus = 1; +try { + const resultStatus = await new Promise((resolve, reject) => { + const child = spawn('xcodebuild', args, { + cwd: SWIFT_DIR, + env: process.env, + }); + + child.stdout.on('data', (chunk) => { + process.stdout.write(redactSecrets(chunk.toString())); + }); + child.stderr.on('data', (chunk) => { + process.stderr.write(redactSecrets(chunk.toString())); + }); + child.once('error', reject); + child.on('close', (code) => resolve(code)); }); - child.on('close', (code) => resolve(code)); -}); + exitStatus = resultStatus ?? 1; -const result = { - status: resultStatus, -}; - -// xcodebuild test exits non-zero on test failure but the result bundle is still valid; -// always try to summarize, then propagate the original exit code. -try { - const summary = readSummary(resultBundle); - console.log(''); - console.log(formatSummaryLine(summary)); - if (summary.failingTests.length > 0) { - console.log(' Failing tests:'); - for (const t of summary.failingTests) { - console.log(` • ${t.identifier}`); + // xcodebuild test exits non-zero on test failure but the result bundle is still valid; + // always try to summarize, then propagate the original exit code. + try { + const summary = readSummary(resultBundle); + console.log(''); + console.log(formatSummaryLine(summary)); + if (summary.failingTests.length > 0) { + console.log(' Failing tests:'); + for (const t of summary.failingTests) { + console.log(` • ${t.identifier}`); + } + } + } catch (err) { + if (err instanceof XcResultError) { + console.error(`⚠️ ${err.message}`); + } else { + throw err; } } -} catch (err) { - if (err instanceof XcResultError) { - console.error(`⚠️ ${err.message}`); - } else { - throw err; - } +} finally { + await localAPI.stop(); } -process.exit(result.status ?? 1); +process.exit(exitStatus); diff --git a/apps/swift/scripts/upload-testflight.ts b/apps/swift/scripts/upload-testflight.ts index c2e0a85a2b..78a0df2810 100644 --- a/apps/swift/scripts/upload-testflight.ts +++ b/apps/swift/scripts/upload-testflight.ts @@ -2,9 +2,11 @@ /** * Archive the native Swift PackRat iOS app and upload it to TestFlight. * - * This targets a SEPARATE App Store Connect record from the production Expo - * app: bundle id `com.andrewbierman.packrat.swift`. Register that app record - * in App Store Connect once before the first upload. + * Choose the App Store Connect lane explicitly: + * --replacement existing Expo/App Store listing (`com.andrewbierman.packrat`, + * display name `PackRat`) for true TestFlight update testing. + * --side-by-side separate Swift beta listing (`com.andrewbierman.packrat.swift`, + * display name `PackRat Swift`) for parallel beta installs. * * Auth uses an Apple ID + app-specific password (no App Store Connect API key * required). Generate a password at appleid.apple.com -> Sign-In & Security -> @@ -13,77 +15,203 @@ * Required env (put in apps/swift/.env.local, gitignored): * APPLE_ID your Apple ID email * APPLE_APP_PASSWORD app-specific password (xxxx-xxxx-xxxx-xxxx) - * APPLE_TEAM_ID the team that owns the record (e.g. 7WV9JYCW55) + * APPLE_TEAM_ID Apple Developer Team ID used for signing * * Optional env: + * APPLE_ASC_PROVIDER App Store Connect provider short name for altool; + * defaults to APPLE_TEAM_ID when omitted * BUILD_NUMBER CFBundleVersion for this upload (default: timestamp) + * MARKETING_VERSION CFBundleShortVersionString for this upload + * (default: the monorepo version from the root + * package.json, which `bun bump` owns) + * APP_STORE_CURRENT_BUILD_NUMBER + * Required for --replacement uploads; latest existing + * PackRat App Store/TestFlight build number. * * Flags: + * --replacement Archive for the existing Expo/App Store iOS listing. + * --side-by-side Archive for the separate Swift beta listing. * --staging Archive the Staging config (PACKRAT_ENV=dev) so the - * TestFlight build targets the deployed DEV API instead - * of production. Default (no flag) = Release/production. + * build targets the deployed DEV API instead of production. + * --production Optional clarity flag; Release/production is the default + * API profile when --staging is absent. + * --dry-run Print the resolved archive identity/settings and exit + * before reading Apple credentials or running Xcode. + * --verify-archive-only Archive, export, inspect binary metadata, then exit + * before reading Apple ID upload credentials. * * Usage: - * bun apps/swift/scripts/upload-testflight.ts # production - * bun apps/swift/scripts/upload-testflight.ts --staging # dev API + * bun apps/swift/scripts/upload-testflight.ts --replacement + * bun apps/swift/scripts/upload-testflight.ts --replacement --dry-run + * bun apps/swift/scripts/upload-testflight.ts --replacement --verify-archive-only + * bun apps/swift/scripts/upload-testflight.ts --side-by-side --staging */ import { execFileSync } from 'node:child_process'; import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { nodeEnv } from '@packrat/env/node'; +import { safeJsonStringify } from '@packrat/utils'; +import { verifyTestFlightArchive, verifyTestFlightIPA } from './lib/testflight-binary'; +import { + parseTestFlightUploadConfig, + TestFlightConfigError, + type TestFlightUploadConfig, + verifyTestFlightReplacementReadiness, + xcodeArchiveOverrides, +} from './lib/testflight-config'; +import { findExportedIPA } from './lib/testflight-export'; const SWIFT_DIR = new URL('..', import.meta.url).pathname; const PROJECT = join(SWIFT_DIR, 'PackRat.xcodeproj'); -const BUNDLE_ID = 'com.andrewbierman.packrat.swift'; +const HELP = process.argv.includes('--help') || process.argv.includes('-h'); +const VERIFY_ARCHIVE_ONLY = process.argv.includes('--verify-archive-only'); -// --staging archives the Staging config (PACKRAT_ENV=dev) via the dedicated -// scheme; the default archives PackRat-iOS (Release config → production). -const STAGING = process.argv.includes('--staging'); -const SCHEME = STAGING ? 'PackRat-iOS-Staging' : 'PackRat-iOS'; -const CONFIGURATION = STAGING ? 'Staging' : 'Release'; +function usage(): string { + return [ + 'Usage:', + ' bun apps/swift/scripts/upload-testflight.ts --replacement [--production|--staging] [--dry-run]', + ' bun apps/swift/scripts/upload-testflight.ts --replacement [--production|--staging] --verify-archive-only', + ' bun apps/swift/scripts/upload-testflight.ts --side-by-side [--production|--staging] [--dry-run]', + '', + 'Lanes:', + ' --replacement Existing Expo/App Store listing: com.andrewbierman.packrat, PackRat.', + ' --side-by-side Separate Swift beta listing: com.andrewbierman.packrat.swift, PackRat Swift.', + ].join('\n'); +} + +if (HELP) { + console.log(usage()); + process.exit(0); +} + +let uploadConfig: TestFlightUploadConfig; +try { + uploadConfig = parseTestFlightUploadConfig({ + argv: process.argv.slice(2), + env: { BUILD_NUMBER: nodeEnv.BUILD_NUMBER, MARKETING_VERSION: nodeEnv.MARKETING_VERSION }, + }); +} catch (error) { + if (error instanceof TestFlightConfigError) { + console.error(`${error.message}\n\n${usage()}`); + process.exit(1); + } + throw error; +} -function req(name: string): string { - const v = process.env[name]; +function printPreflight(input: { + config: TestFlightUploadConfig; + teamId?: string; + ascProvider?: string; +}) { + const { + config, + teamId = '', + ascProvider = '', + } = input; + const archiveOverrides = xcodeArchiveOverrides({ config, teamId }); + console.log( + safeJsonStringify({ + lane: config.lane, + bundleId: config.bundleId, + watchBundleId: config.watchBundleId, + companionBundleId: config.companionBundleId, + displayName: config.displayName, + scheme: config.scheme, + configuration: config.configuration, + apiEnvironment: config.apiEnvironment, + marketingVersion: config.marketingVersion, + buildNumber: config.buildNumber, + ascProvider, + archiveOverrides, + }), + ); +} + +if (uploadConfig.dryRun) { + printPreflight({ config: uploadConfig, ascProvider: nodeEnv.APPLE_ASC_PROVIDER }); + process.exit(0); +} + +function req(input: { name: 'APPLE_ID' | 'APPLE_APP_PASSWORD' | 'APPLE_TEAM_ID' }): string { + const v = nodeEnv[input.name]; if (!v) { - console.error(`Missing required env var: ${name}. See script header.`); + console.error(`Missing required env var: ${input.name}. See script header.`); process.exit(1); } return v; } -const appleId = req('APPLE_ID'); -const appPassword = req('APPLE_APP_PASSWORD'); -const teamId = req('APPLE_TEAM_ID'); -const buildNumber = process.env.BUILD_NUMBER ?? String(Math.floor(Date.now() / 1000)); +if (nodeEnv.BUILD_NUMBER) { + uploadConfig = { ...uploadConfig, buildNumber: nodeEnv.BUILD_NUMBER }; +} + +if (uploadConfig.lane === 'replacement') { + const readiness = verifyTestFlightReplacementReadiness({ + config: uploadConfig, + currentAppStoreBuildNumber: nodeEnv.APP_STORE_CURRENT_BUILD_NUMBER, + requireCurrentAppStoreBuildNumber: true, + }); + if (!readiness.ok) { + for (const error of readiness.errors) + console.error(`Replacement TestFlight preflight failed: ${error}`); + process.exit(1); + } +} + +const teamId = req({ name: 'APPLE_TEAM_ID' }); +const appleId = VERIFY_ARCHIVE_ONLY ? undefined : req({ name: 'APPLE_ID' }); +const appPassword = VERIFY_ARCHIVE_ONLY ? undefined : req({ name: 'APPLE_APP_PASSWORD' }); +const ascProvider = nodeEnv.APPLE_ASC_PROVIDER ?? teamId; +printPreflight({ config: uploadConfig, teamId, ascProvider }); const work = mkdtempSync(join(tmpdir(), 'packrat-tf-')); const archivePath = join(work, 'PackRat.xcarchive'); const exportDir = join(work, 'export'); -function run(cmd: string, args: string[]) { +function run(input: { cmd: string; args: string[] }) { + const { cmd, args } = input; console.log(`\n$ ${cmd} ${args.join(' ')}`); execFileSync(cmd, args, { stdio: 'inherit' }); } +function verifyBinary(input: { + label: string; + result: ReturnType; +}) { + const { label, result } = input; + if (!result.ok) { + for (const error of result.errors) console.error(`${label} verification failed: ${error}`); + process.exit(1); + } + console.log(`✓ Verified ${label} metadata (${result.iosApp}, ${result.watchApp})`); +} + // 1. Archive for a real device (TestFlight cannot accept a simulator build). -run('xcodebuild', [ - 'archive', - '-project', - PROJECT, - '-scheme', - SCHEME, - '-configuration', - CONFIGURATION, - '-destination', - 'generic/platform=iOS', - '-archivePath', - archivePath, - // Lets Xcode register the App IDs and generate provisioning profiles for - // the (new) bundle ids on the fly, using the signed-in account. - '-allowProvisioningUpdates', - `CURRENT_PROJECT_VERSION=${buildNumber}`, - `DEVELOPMENT_TEAM=${teamId}`, -]); +run({ + cmd: 'xcodebuild', + args: [ + 'archive', + '-project', + PROJECT, + '-scheme', + uploadConfig.scheme, + '-configuration', + uploadConfig.configuration, + '-destination', + 'generic/platform=iOS', + '-archivePath', + archivePath, + // Lets Xcode register the App IDs and generate provisioning profiles for + // the (new) bundle ids on the fly, using the signed-in account. + '-allowProvisioningUpdates', + ...xcodeArchiveOverrides({ config: uploadConfig, teamId }), + ], +}); +verifyBinary({ + label: 'TestFlight archive', + result: verifyTestFlightArchive({ archivePath, config: uploadConfig }), +}); // 2. Export a signed .ipa for App Store distribution. const exportOptions = join(work, 'ExportOptions.plist'); @@ -103,40 +231,56 @@ writeFileSync( `, ); -run('xcodebuild', [ - '-exportArchive', - '-archivePath', - archivePath, - '-exportPath', - exportDir, - '-exportOptionsPlist', - exportOptions, - // Export also needs to generate the App Store distribution profiles for the - // new bundle ids on the fly. - '-allowProvisioningUpdates', -]); +run({ + cmd: 'xcodebuild', + args: [ + '-exportArchive', + '-archivePath', + archivePath, + '-exportPath', + exportDir, + '-exportOptionsPlist', + exportOptions, + // Export also needs to generate the App Store distribution profiles for the + // new bundle ids on the fly. + '-allowProvisioningUpdates', + ], +}); // 3. Upload to TestFlight via altool (app-specific-password auth). // `--asc-provider` (team short name) is required when the Apple ID belongs to // more than one team, so altool knows which one to deliver to. -const ipa = join(exportDir, `${SCHEME}.ipa`); -run('xcrun', [ - 'altool', - '--upload-app', - '--type', - 'ios', - '--file', - ipa, - '--username', - appleId, - '--password', - appPassword, - '--asc-provider', - teamId, -]); +const ipa = findExportedIPA(exportDir); +verifyBinary({ + label: 'TestFlight IPA', + result: verifyTestFlightIPA({ ipaPath: ipa, config: uploadConfig }), +}); + +if (VERIFY_ARCHIVE_ONLY) { + console.log('\n✓ Archive/export verification passed; skipping TestFlight upload.'); + process.exit(0); +} + +run({ + cmd: 'xcrun', + args: [ + 'altool', + '--upload-app', + '--type', + 'ios', + '--file', + ipa, + '--username', + appleId ?? '', + '--password', + appPassword ?? '', + '--asc-provider', + ascProvider, + ], +}); console.log( - `\n✓ Uploaded build ${buildNumber} to TestFlight (${BUNDLE_ID}, ${CONFIGURATION}` + - `${STAGING ? ' → dev API' : ' → production'}).`, + `\n✓ Uploaded build ${uploadConfig.buildNumber} to TestFlight (${uploadConfig.bundleId}, ${uploadConfig.displayName}, ${uploadConfig.configuration}` + + `${uploadConfig.staging ? ' -> dev API' : ' -> production'}, ${uploadConfig.lane}).`, ); console.log('It will appear in App Store Connect after processing (usually 5-15 min).'); diff --git a/apps/swift/scripts/validate-app-store-assets.ts b/apps/swift/scripts/validate-app-store-assets.ts index 1bf92a6ae2..92dbfaf0ec 100644 --- a/apps/swift/scripts/validate-app-store-assets.ts +++ b/apps/swift/scripts/validate-app-store-assets.ts @@ -2,9 +2,12 @@ import { resolve } from 'node:path'; import { validateAppIconSet } from './lib/app-store-assets'; const repoRoot = resolve(import.meta.dir, '../../..'); -const iconSetDir = resolve(repoRoot, 'apps/swift/Resources/Assets.xcassets/AppIcon.appiconset'); +const iconSetDirs = [ + resolve(repoRoot, 'apps/swift/Resources/Assets.xcassets/AppIcon.appiconset'), + resolve(repoRoot, 'apps/swift/Resources/WatchAssets.xcassets/WatchAppIcon.appiconset'), +]; -const issues = validateAppIconSet(iconSetDir); +const issues = iconSetDirs.flatMap((iconSetDir) => validateAppIconSet(iconSetDir)); if (issues.length > 0) { console.error('App Store asset validation failed:'); diff --git a/apps/swift/scripts/verify-deployed-auth.ts b/apps/swift/scripts/verify-deployed-auth.ts new file mode 100644 index 0000000000..76aa4b1431 --- /dev/null +++ b/apps/swift/scripts/verify-deployed-auth.ts @@ -0,0 +1,113 @@ +import { nodeEnv } from '@packrat/env/node'; +import { isObject, isString } from '@packrat/guards'; +import { safeJsonStringify } from '@packrat/utils'; + +type AuthPreflightInput = { + apiBaseURL: string | undefined; + email: string | undefined; + password: string | undefined; + fetchImpl?: FetchLike; +}; + +type FetchLike = ( + input: string | URL, + init?: RequestInit, +) => Promise>; + +type AuthResponseBody = Record & { + user?: unknown; + token?: unknown; + message?: unknown; + error?: unknown; + code?: unknown; +}; + +function required(input: { value: string | undefined; name: string }): string { + const { value, name } = input; + const trimmed = value?.trim(); + if (!trimmed) throw new Error(`Missing deployed auth preflight input: ${name}`); + return trimmed; +} + +function responseMessage(input: { body: AuthResponseBody; status: number }): string { + const { body, status } = input; + for (const value of [body.message, body.error, body.code]) { + if (isString(value) && value.trim()) return value.trim(); + } + return `HTTP ${status}`; +} + +function authFailureMessage(input: { body: AuthResponseBody; status: number; apiBaseURL: string }) { + const message = responseMessage(input); + if (input.status === 401) { + return `${message}. Check that E2E_TEST_EMAIL/E2E_TEST_PASSWORD match a real QA user on ${input.apiBaseURL}; production is not seeded by Swift CI.`; + } + return message; +} + +async function parseBody(response: Pick): Promise { + try { + const body = await response.json(); + if (!isObject(body)) return {}; + return { + user: body.user, + token: body.token, + message: body.message, + error: body.error, + code: body.code, + }; + } catch { + return {}; + } +} + +function signInURL(apiBaseURL: string): URL { + const normalizedBaseURL = apiBaseURL.endsWith('/') ? apiBaseURL : `${apiBaseURL}/`; + return new URL('api/auth/sign-in/email', normalizedBaseURL); +} + +export async function verifyDeployedAuth(input: AuthPreflightInput): Promise { + const fetchImpl = input.fetchImpl ?? fetch; + const apiBaseURL = required({ value: input.apiBaseURL, name: 'E2E_API_BASE_URL' }); + const email = required({ value: input.email, name: 'E2E_EMAIL' }); + const password = required({ value: input.password, name: 'E2E_PASSWORD' }); + + const response = await fetchImpl(signInURL(apiBaseURL), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'packrat://', + }, + body: safeJsonStringify({ email, password }), + }); + const body = await parseBody(response); + const token = response.headers.get('set-auth-token') ?? body.token; + + if (!response.ok) { + throw new Error( + `Swift deployed auth preflight failed: ${authFailureMessage({ + body, + status: response.status, + apiBaseURL, + })}`, + ); + } + + if (!body.user || !token) { + throw new Error('Swift deployed auth preflight succeeded without user or session token'); + } +} + +if (import.meta.main) { + try { + await verifyDeployedAuth({ + apiBaseURL: nodeEnv.E2E_API_BASE_URL, + email: nodeEnv.E2E_EMAIL, + password: nodeEnv.E2E_PASSWORD, + }); + console.log('Swift deployed auth preflight passed'); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/apps/swift/scripts/verify-testflight-replacement.ts b/apps/swift/scripts/verify-testflight-replacement.ts new file mode 100644 index 0000000000..03fbd4c41b --- /dev/null +++ b/apps/swift/scripts/verify-testflight-replacement.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env bun +/** + * Verifies that the Swift TestFlight upload settings are compatible with a + * seamless update of the existing Expo iOS listing. + * + * This is a preflight gate only; it does not archive or upload. + * + * Usage: + * APP_STORE_CURRENT_BUILD_NUMBER=123 BUILD_NUMBER=456 \ + * bun apps/swift/scripts/verify-testflight-replacement.ts --replacement --production + */ +import { nodeEnv } from '@packrat/env/node'; +import { safeJsonStringify } from '@packrat/utils'; +import { + parseTestFlightUploadConfig, + TestFlightConfigError, + verifyTestFlightReplacementReadiness, +} from './lib/testflight-config'; + +const HELP = process.argv.includes('--help') || process.argv.includes('-h'); + +function usage(): string { + return [ + 'Usage:', + ' bun apps/swift/scripts/verify-testflight-replacement.ts --replacement --production', + '', + 'Recommended env:', + ' BUILD_NUMBER build number intended for upload', + ' APP_STORE_CURRENT_BUILD_NUMBER latest existing PackRat App Store/TestFlight build', + ].join('\n'); +} + +if (HELP) { + console.log(usage()); + process.exit(0); +} + +try { + const config = parseTestFlightUploadConfig({ + argv: process.argv.slice(2), + env: { BUILD_NUMBER: nodeEnv.BUILD_NUMBER, MARKETING_VERSION: nodeEnv.MARKETING_VERSION }, + }); + const readiness = verifyTestFlightReplacementReadiness({ + config, + currentAppStoreBuildNumber: nodeEnv.APP_STORE_CURRENT_BUILD_NUMBER, + requireCurrentAppStoreBuildNumber: true, + }); + const report = { + lane: config.lane, + bundleId: config.bundleId, + watchBundleId: config.watchBundleId, + companionBundleId: config.companionBundleId, + displayName: config.displayName, + configuration: config.configuration, + apiEnvironment: config.apiEnvironment, + marketingVersion: config.marketingVersion, + buildNumber: config.buildNumber, + currentAppStoreBuildNumber: nodeEnv.APP_STORE_CURRENT_BUILD_NUMBER ?? null, + ok: readiness.ok, + errors: readiness.errors, + warnings: readiness.warnings, + }; + + console.log(safeJsonStringify(report, null, 2)); + if (!readiness.ok) process.exit(1); +} catch (error) { + if (error instanceof TestFlightConfigError) { + console.error(`${error.message}\n\n${usage()}`); + process.exit(1); + } + throw error; +} diff --git a/apps/swift/scripts/watch-sync-smoke.ts b/apps/swift/scripts/watch-sync-smoke.ts index 42449f583b..309703ef20 100644 --- a/apps/swift/scripts/watch-sync-smoke.ts +++ b/apps/swift/scripts/watch-sync-smoke.ts @@ -20,8 +20,13 @@ type SimulatorPair = { const REPO_ROOT = resolve(import.meta.dir, '../../..'); const SWIFT_DIR = resolve(REPO_ROOT, 'apps/swift'); const ARTIFACT_DIR = resolve(REPO_ROOT, 'artifacts/screenshots-latest'); -const IOS_BUNDLE_ID = 'com.andrewbierman.packrat'; -const WATCH_BUNDLE_ID = 'com.andrewbierman.packrat.watchkitapp'; +const IOS_BUNDLE_ID = nodeEnv.PACKRAT_IOS_BUNDLE_ID ?? 'com.andrewbierman.packrat'; +const WATCH_BUNDLE_ID = nodeEnv.PACKRAT_WATCH_BUNDLE_ID ?? 'com.andrewbierman.packrat.watchkitapp'; +const KNOWN_IOS_BUNDLE_IDS = ['com.andrewbierman.packrat', 'com.andrewbierman.packrat.swift']; +const KNOWN_WATCH_BUNDLE_IDS = [ + 'com.andrewbierman.packrat.watchkitapp', + 'com.andrewbierman.packrat.swift.watchkitapp', +]; const WAIT_MS = Number(nodeEnv.PACKRAT_WATCH_SYNC_WAIT_MS ?? 45_000); // biome-ignore lint/complexity/useMaxParams: command wrappers read like shell invocations. @@ -200,10 +205,7 @@ async function waitForWatchSnapshot(watchId: string, timeoutMs: number): Promise 'data', ]); if (container) { - const preferences = resolve( - container, - 'Library/Preferences/com.andrewbierman.packrat.watchkitapp.plist', - ); + const preferences = resolve(container, `Library/Preferences/${WATCH_BUNDLE_ID}.plist`); const payload = outputOrNull('/usr/libexec/PlistBuddy', [ '-c', 'Print :watch.snapshot', @@ -256,16 +258,27 @@ async function main() { const iosAppPath = appPath('PackRat-iOS', phoneDestination); const standaloneWatchAppPath = appPath('PackRat-Watch', watchDestination); - const embeddedWatchAppPath = resolve(iosAppPath, 'Watch/PackRat-Watch.app'); + const embeddedWatchCandidates = [ + resolve(iosAppPath, 'PlugIns/PackRat-Watch.app'), + resolve(iosAppPath, 'Watch/PackRat-Watch.app'), + ]; + const embeddedWatchAppPath = embeddedWatchCandidates.find((candidate) => existsSync(candidate)); if (!existsSync(iosAppPath)) throw new Error(`Missing iOS app at ${iosAppPath}`); - if (!existsSync(embeddedWatchAppPath)) - throw new Error(`Missing embedded Watch app at ${embeddedWatchAppPath}`); + if (!embeddedWatchAppPath) { + throw new Error(`Missing embedded Watch app at ${embeddedWatchCandidates.join(' or ')}`); + } if (!existsSync(standaloneWatchAppPath)) throw new Error(`Missing Watch app at ${standaloneWatchAppPath}`); console.log('-> Installing apps'); - run('xcrun', ['simctl', 'uninstall', phoneId, IOS_BUNDLE_ID], { allowFailure: true }); - run('xcrun', ['simctl', 'uninstall', watchId, WATCH_BUNDLE_ID], { allowFailure: true }); + for (const bundleId of KNOWN_IOS_BUNDLE_IDS) { + run('xcrun', ['simctl', 'terminate', phoneId, bundleId], { allowFailure: true }); + run('xcrun', ['simctl', 'uninstall', phoneId, bundleId], { allowFailure: true }); + } + for (const bundleId of KNOWN_WATCH_BUNDLE_IDS) { + run('xcrun', ['simctl', 'terminate', watchId, bundleId], { allowFailure: true }); + run('xcrun', ['simctl', 'uninstall', watchId, bundleId], { allowFailure: true }); + } await installAppWithRetry(phoneId, IOS_BUNDLE_ID, iosAppPath); await installAppWithRetry(watchId, WATCH_BUNDLE_ID, standaloneWatchAppPath); await waitForInstalledApp(phoneId, IOS_BUNDLE_ID); @@ -298,6 +311,7 @@ async function main() { { env: { SIMCTL_CHILD_PACKRAT_VISUAL_SAMPLE_DATA: '1', + SIMCTL_CHILD_PACKRAT_E2E_ALLOW_LOGIN_SEED: '1', SIMCTL_CHILD_PACKRAT_E2E_EMAIL: 'e2e@packrat.test', SIMCTL_CHILD_PACKRAT_E2E_USER_ID: '00000000-0000-4000-8000-000000000001', SIMCTL_CHILD_PACKRAT_E2E_ROLE: 'ADMIN', diff --git a/apps/swift/xcconfig/Config-Debug.xcconfig b/apps/swift/xcconfig/Config-Debug.xcconfig index b3e6e8cb81..cbcb9dd1bf 100644 --- a/apps/swift/xcconfig/Config-Debug.xcconfig +++ b/apps/swift/xcconfig/Config-Debug.xcconfig @@ -19,6 +19,7 @@ // echo "PACKRAT_ENV = dev" > xcconfig/Config-Debug.local.xcconfig # deployed dev // echo "PACKRAT_ENV = dev-local" > xcconfig/Config-Debug.local.xcconfig # wrangler on :8791 PACKRAT_ENV = local +PACKRAT_DISPLAY_NAME = PackRat // SENTRY_DSN flows xcconfig → Info.plist → runtime. // Drop the DSN into Config-Debug.local.xcconfig to enable Sentry on Debug diff --git a/apps/swift/xcconfig/Config-Release.xcconfig b/apps/swift/xcconfig/Config-Release.xcconfig index 5be651e2c3..0f22cac0d0 100644 --- a/apps/swift/xcconfig/Config-Release.xcconfig +++ b/apps/swift/xcconfig/Config-Release.xcconfig @@ -1,4 +1,5 @@ PACKRAT_ENV = production +PACKRAT_DISPLAY_NAME = PackRat // SENTRY_DSN flows xcconfig → Info.plist → runtime. Set this in the build // environment (CI secret, Release-build script, or Config-Release.local.xcconfig diff --git a/apps/swift/xcconfig/Config-Staging.xcconfig b/apps/swift/xcconfig/Config-Staging.xcconfig index 70ea560e26..d4d2868e98 100644 --- a/apps/swift/xcconfig/Config-Staging.xcconfig +++ b/apps/swift/xcconfig/Config-Staging.xcconfig @@ -11,6 +11,7 @@ // xcconfig files treat // as a comment, so full URLs can't be stored here. // PACKRAT_ENV selects an environment name; URLs live in APIClient.swift. PACKRAT_ENV = dev +PACKRAT_DISPLAY_NAME = PackRat // SENTRY_DSN flows xcconfig → Info.plist → runtime. Provided by CI from a // secret at archive time; empty disables Sentry (SDK silently no-ops). diff --git a/apps/trails/package.json b/apps/trails/package.json index 1d8b1c790a..2f21451f82 100644 --- a/apps/trails/package.json +++ b/apps/trails/package.json @@ -1,6 +1,6 @@ { "name": "packrat-trails-app", - "version": "2.1.0", + "version": "2.2.0", "private": true, "scripts": { "build": "bun run generate-og-images && next build", diff --git a/docs/plans/2026-07-17-001-fix-swift-testflight-loading-and-pr-readiness-plan.md b/docs/plans/2026-07-17-001-fix-swift-testflight-loading-and-pr-readiness-plan.md new file mode 100644 index 0000000000..6b2c27bedb --- /dev/null +++ b/docs/plans/2026-07-17-001-fix-swift-testflight-loading-and-pr-readiness-plan.md @@ -0,0 +1,284 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +title: "fix: Swift TestFlight loading and PR readiness" +type: fix +status: active +created: 2026-07-17 +target_branch: codex/swift-beta-tester-readiness +base_branch: development +product_contract_source: ce-plan-bootstrap +--- + +# fix: Swift TestFlight loading and PR readiness + +## Goal Capsule + +Make the Swift beta PR mergeable and prove the TestFlight-like build path is not hiding loading, auth, feature-flag, or production API failures behind local-only E2E fixtures. + +## Product Contract + +### Problem Frame + +The Swift beta branch has substantial iOS, iPad, macOS, and watchOS polish and E2E work, but the user still observes TestFlight loading issues and worries that local tests are not exercising the deployed release path. The active PR is open but GitHub marks it conflicting against `development`, so the branch cannot be safely merged until conflict resolution and production-like validation are complete. + +### Requirements + +- **R1**: Resolve the PR conflicts against `development` without dropping the Swift beta, visual screenshot, or E2E coverage work. +- **R2**: Reproduce TestFlight-like loading behavior using Release/production API configuration, not only local deterministic E2E fixtures. +- **R3**: Exercise Swift feature flags in at least the canonical default profile and a temporary all-on profile so disabled feature surfaces do not silently rot. +- **R4**: Keep tests honest: do not hide real app/backend bugs by weakening assertions, forcing success states, or seeding user-confusing dummy production data. +- **R5**: Preserve guest and authenticated coverage across iOS, iPad, macOS, and relevant watchOS surfaces where the local tooling supports it. +- **R6**: Ship any fixes as small gitmoji commits to the existing PR branch and watch CI to a decided state. +- **R7**: If unrelated repo-wide checks fail, separate them from the Swift beta readiness path and only hand off to another worktree/subagent when the scope is cleanly separable. + +### Acceptance Examples + +- **AE1**: Running the Swift E2E workflow manually with `api_environment=production` and `feature_flag_profile=default` reaches authenticated app screens with real production test credentials or fails with a specific app/API defect. +- **AE2**: Running the Swift visual workflow with `api_environment=production` produces iOS, iPad, macOS, and watchOS contact sheets without indefinite loading states on authenticated screens. +- **AE3**: Running local default and all-on feature flag config generation produces expected generated Swift flags and does not leave the working tree dirty after returning to default. +- **AE4**: GitHub PR #2627 no longer reports `CONFLICTING`. + +## Scope Boundaries + +In scope: +- Conflict resolution for the active Swift beta readiness PR. +- Swift app/API fixes needed to make production-like TestFlight loading, auth, and screenshots work. +- Manual workflow hardening where the deployed validation path needs clearer inputs or artifacts. +- Focused tests for any behavior changes. + +Out of scope: +- Expo E2E fixes unrelated to the Swift beta rollout. +- Production database dummy seeding that would confuse testers. +- Apple Developer/App Store Connect account changes unless required to inspect TestFlight metadata. +- Large unrelated repo-wide lint cleanup unless it directly blocks the Swift PR. + +## Settled Decisions + +- **KTD-session-settled-1**: Validate Swift iOS and macOS first; do not get caught up in Expo web/E2E right now. + - Provenance: user-directed. + - Rejected alternative: broaden the current work into Expo web or GitHub Expo E2E repair. + - Reason: the beta tester risk is the Swift app and TestFlight path. +- **KTD-session-settled-2**: Keep the app local-first and guest-capable, while testing authenticated mode too. + - Provenance: user-approved. + - Rejected alternative: gate all useful screens behind auth or test only guest mode. + - Reason: testers need basic functionality without sign-in, and production issues are often auth-only. +- **KTD-session-settled-3**: Use SwiftUI-native defaults and reusable empty/error/loading patterns. + - Provenance: user-approved. + - Rejected alternative: custom controls and inconsistent ad hoc state views. + - Reason: the desired product feel is Apple-native and maintainable. +- **KTD-session-settled-4**: Use visual contact sheets as a required review artifact. + - Provenance: user-approved. + - Rejected alternative: rely only on textual test output. + - Reason: screenshots revealed error, empty-state, layout, and modal coverage gaps. +- **Report conflicts**: If implementation finds a settled decision is infeasible or harmful, stop for invalidating conflicts; proceed with a documented note for preference-grade conflicts. + +## Current Context + +- PR #2627 exists: `https://github.com/PackRat-AI/PackRat/pull/2627`. +- Local branch is clean and tracks `origin/codex/swift-beta-tester-readiness`. +- GitHub currently reports PR #2627 as `CONFLICTING`. +- CodeQL checks are green at the latest observed state. +- This worktree originally tracked only the PR branch; fetch refs for `origin/development` and `origin/main` must be available before merge/rebase work. +- Release Swift config uses `PACKRAT_ENV = production`. +- Production API base URL is `https://packrat-api.orange-frost-d665.workers.dev`. +- Local Swift E2E and screenshots can use deterministic local E2E API fixtures, which is useful but does not prove TestFlight behavior. + +## Implementation Units + +### U1. Resolve base sync and PR conflicts + +**Goal:** Bring the branch onto current `development` and make PR #2627 mergeable. + +**Requirements:** R1, R6. + +**Files:** +- Potentially any conflicted files reported by merging or rebasing `origin/development`. +- Likely conflict candidates: `.github/workflows/swift-e2e.yml`, `.github/workflows/swift-visual.yml`, `apps/swift/project.yml`, Swift test files, and API E2E fixture routes. + +**Approach:** +- Fetch `origin/development` explicitly because this worktree uses a narrow fetch refspec. +- Use a non-destructive merge or rebase strategy that preserves the PR branch work and does not revert unrelated user changes. +- Inspect each conflict manually and preserve both development-side updates and Swift beta additions when compatible. +- Regenerate any generated Swift config/project artifacts only through repo scripts. + +**Test scenarios:** +- PR conflict state changes from `CONFLICTING` to mergeable after push. +- Local diff still includes the Swift beta screenshot/E2E/watch/app-icon work expected for this PR. +- No conflict markers remain. + +**Verification:** +- `git diff --check`. +- `rg -n '<<<<<<<|=======|>>>>>>>'`. +- `gh pr view 2627 --json mergeable`. + +### U2. Reproduce TestFlight-like loading locally and in workflow + +**Goal:** Determine whether TestFlight loading is a real production API/auth/config bug, a Release build configuration issue, or a workflow coverage gap. + +**Requirements:** R2, R4, R5. + +**Files:** +- `apps/swift/xcconfig/Config-Release.xcconfig` +- `apps/swift/Sources/PackRat/Network/APIClient.swift` +- `apps/swift/Sources/PackRat/Network/AuthManager.swift` +- `apps/swift/Sources/PackRat/PackRatApp.swift` +- `apps/swift/Tests/PackRatUITests/AppUITestCase.swift` +- `apps/swift/Tests/PackRatUITests/AuthTests.swift` +- `apps/swift/scripts/run-e2e.ts` +- `apps/swift/scripts/run-e2e-macos.ts` +- `apps/swift/scripts/capture-visual-screenshots.ts` +- `.github/workflows/swift-e2e.yml` +- `.github/workflows/swift-visual.yml` + +**Approach:** +- Run a production-target smoke path with `PACKRAT_ENV=production` and explicit production `E2E_API_BASE_URL` using existing test credentials when available. +- If credentials are unavailable locally, make the workflow produce a clear missing-secret failure instead of falling back to local fixtures. +- Compare app launch environment, API base URL selection, auth cookie/session handling, and loading/error states between local and production targets. +- Fix any app bug that causes indefinite loading, incorrect connection-needed fallback, or authenticated screens to fail with production-shaped responses. +- Keep production tests read-safe; do not seed production with fake tester-visible records. + +**Test scenarios:** +- Guest launch reaches Home without indefinite loading when production API is reachable. +- Authenticated login reaches the authenticated Home/profile/packs surfaces or fails with a specific auth assertion. +- Protected route `401` maps to sign-in-required or logged-out state, not generic connection-needed. +- API/network failures map to reusable centered offline/error states. +- Production API base URL is visible in logs/artifacts without leaking secrets. + +**Verification:** +- Local smoke command with `PACKRAT_ENV=production` where credentials exist. +- Manual GitHub workflow run with `api_environment=production`. +- Screenshot catalog review for loading/error states. + +### U3. Feature flag matrix hardening + +**Goal:** Exercise both default Swift beta flags and an all-on exploratory profile without committing temporary generated flag changes. + +**Requirements:** R3, R4, R5. + +**Files:** +- `apps/swift/scripts/generate-swift-config.ts` +- `apps/swift/Sources/PackRat/Config/AppFeatureFlags.swift` +- `apps/swift/Tests/PackRatUITests/UITestFeatureFlags.swift` +- `apps/swift/Tests/PackRatUITests/VisualScreenshotTests.swift` +- `.github/workflows/swift-e2e.yml` +- `.github/workflows/swift-visual.yml` + +**Approach:** +- Validate generated flags under `default`, `all-on`, and restored `default`. +- Run a focused local all-on screenshot/E2E subset if runtime permits. +- If all-on exposes intentionally incomplete features, either fix the feature enough for the beta or keep it disabled and ensure tests explicitly skip it by generated flag. + +**Test scenarios:** +- Default profile reflects canonical app config. +- All-on profile enables every generated Swift flag. +- All-off profile does not crash launch/navigation. +- Generated files return to default before commit unless the product config itself changes. + +**Verification:** +- `bun run swift:config`. +- `PACKRAT_SWIFT_FEATURE_FLAG_PROFILE=all-on bun run swift:config`. +- `PACKRAT_SWIFT_FEATURE_FLAG_PROFILE=all-off bun run swift:config`. +- Restore default and verify `git diff` is expected. + +### U4. Screenshot/contact-sheet audit for loading and missing states + +**Goal:** Produce visual evidence that all major Swift states render correctly under default and production-like configurations. + +**Requirements:** R2, R4, R5. + +**Files:** +- `apps/swift/scripts/capture-visual-screenshots.ts` +- `apps/swift/Tests/PackRatUITests/VisualScreenshotTests.swift` +- `apps/swift/Sources/PackRat/Shared/ErrorView.swift` +- `apps/swift/Sources/PackRat/Shared/VisualSampleData.swift` +- Feature screens under `apps/swift/Sources/PackRat/Features/**` + +**Approach:** +- Run or trigger the visual capture catalog for iOS, iPad, macOS, and watchOS. +- Review contact sheets manually for indefinite spinners, top-aligned empty/error states, missing icons, clipped forms, broken modals, or mislabeled auth/offline states. +- Fix reusable state components before touching individual screens unless a screen has a unique bug. + +**Test scenarios:** +- Guest and auth screenshot sets include Home, feature lists, details, forms, empty states, error/offline states, modals, menus, and disabled-feature behavior. +- Auth screenshots with production target do not show broad connection-needed states after successful login. +- Empty/error/loading states are centered and consistent where appropriate. + +**Verification:** +- Screenshot artifacts exist as individual images plus contact sheets. +- Visual screenshot test suite passes or reports specific expected skips. + +### U5. Focused app/API fixes and tests + +**Goal:** Fix real defects uncovered by U2-U4 and protect them with tests. + +**Requirements:** R2, R4, R5. + +**Files:** +- Determined by the reproduced defect. +- Likely areas: Swift auth/session handling, API response decoding, local/offline state routing, and E2E local worker parity with production responses. + +**Approach:** +- Characterize failing behavior first. +- Add or update the narrowest test that would have failed before the fix. +- Fix the app/API behavior rather than hiding failures in screenshot fixtures. +- Keep local E2E fixtures production-shaped enough to catch decoder and status-state bugs. + +**Test scenarios:** +- Any fixed loading bug has a unit, UI, or runner test that proves the expected settled state. +- Any API fixture change has a route test. +- Any auth classification change distinguishes unauthenticated, offline, and server-error states. + +**Verification:** +- Focused Swift unit/UI tests for changed surfaces. +- Focused API Vitest tests for changed API fixtures/routes. +- `bun run test:swift:scripts`. +- Touched-file lint/checks. + +### U6. Optional separate worktree/subagent for unrelated repo-wide failures + +**Goal:** Avoid blocking Swift readiness on unrelated checks while still capturing easy independent fixes. + +**Requirements:** R7. + +**Files:** +- Only files outside the Swift beta PR scope if the failure is clearly unrelated. + +**Approach:** +- If repo-wide pre-push checks fail on unrelated Expo/docs/packages, classify the failure. +- If it is easy and isolated, create a separate worktree/branch and fix it independently. +- If it is broad or product-risky, document it as unrelated residual and do not mix it into the Swift PR. + +**Test scenarios:** +- Swift PR can be reviewed without unrelated cleanup noise. +- Any separate cleanup branch has its own focused verification. + +**Verification:** +- Separate PR or durable note for unrelated residuals. +- Swift PR status remains focused on Swift beta readiness. + +## Dependencies and Risks + +- Production E2E requires valid production-safe `E2E_TEST_EMAIL` and `E2E_TEST_PASSWORD` secrets. Without them, the deployed workflow can only prove guest and unauthenticated behavior. +- GitHub-hosted macOS runners may not support every simulator/watchOS runtime needed for the full visual matrix; local machine artifacts remain acceptable for device coverage that GitHub cannot provide. +- Feature flags disabled in canonical config may expose incomplete surfaces under all-on. That is useful signal, not an automatic mandate to ship those features enabled. +- Resolving conflicts may reveal that `development` already changed API or Swift behavior in ways that invalidate earlier local screenshots. + +## Verification Plan + +- `git diff --check` +- `rg -n '<<<<<<<|=======|>>>>>>>'` +- `bun run test:swift:scripts` +- Focused API Vitest tests for changed API fixture/routes. +- Focused Swift unit/UI/E2E commands for changed Swift surfaces. +- Swift visual screenshot capture for available iOS, iPad, macOS, and watchOS targets. +- Manual GitHub workflow runs for production/default and, if practical, all-on feature flag profiles. +- `gh pr view 2627 --json mergeable,statusCheckRollup` + +## Implementation Notes + +- Use small gitmoji commits. +- Do not commit temporary all-on/all-off generated Swift flag files. +- Do not put secrets in logs, screenshots, workflow summaries, or committed artifacts. +- Prefer fixing reusable SwiftUI state/form components over one-off per-screen layout patches. diff --git a/docs/plans/2026-07-23-001-fix-swift-tester-issues-plan.md b/docs/plans/2026-07-23-001-fix-swift-tester-issues-plan.md new file mode 100644 index 0000000000..aa52fc98e4 --- /dev/null +++ b/docs/plans/2026-07-23-001-fix-swift-tester-issues-plan.md @@ -0,0 +1,56 @@ +--- +title: Fix Swift tester issues 2640-2642 +date: 2026-07-23 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# Fix Swift tester issues 2640-2642 + +## Product Contract + +### Requirements + +- R1: Opening AI Assistant from Home must create native back navigation to Home. +- R2: Opening the primary Assistant tab must remain a root destination without synthetic history. +- R3: Gear Inventory sorting must use a balanced native menu that exposes and updates the selected order accessibly. +- R4: Weather current, feels-like, forecast-high, and forecast-low temperatures must honor the saved Celsius/Fahrenheit preference. +- R5: Weather formatting must prefer the API field matching the selected unit, convert the alternate field when necessary, and show a placeholder only when both are unavailable. + +### Acceptance Evidence + +- AE1: A focused UI test covers Home-origin Assistant back navigation and direct-tab history. +- AE2: A focused UI test covers the Gear sort menu options and selected accessibility value. +- AE3: Unit tests cover both preferred fields, both conversion fallbacks, and missing data. +- AE4: A focused UI test addresses all four Weather temperature surfaces independently. +- AE5: The PackRat iOS simulator target builds successfully. + +## Key Technical Decisions + +- KTD1: Home accepts an optional Assistant-opening callback; the compact iPhone Home stack supplies it, while all existing Home callers retain global navigation behavior. +- KTD2: The Home stack does not mirror its pushed `.chat` route into global tab selection, preventing the tab observer from erasing native back history. +- KTD3: Gear sorting uses an explicit `Menu` with a current-value label, selection indicator, and accessibility value. +- KTD4: A pure formatter centralizes preferred-field selection, fallback conversion, rounding, suffixes, and missing-value behavior. + +## Implementation Units + +### U1: Source-sensitive Assistant navigation + +Implements R1-R2 and AE1 using `HomeView`, `AppNavigation`, and `MoreTabsTests`. + +### U2: Explicit Gear sorting menu + +Implements R3 and AE2 using `GearInventoryView` and `MoreTabsTests`. + +### U3: Preference-aware Weather temperatures + +Implements R4-R5 and AE3-AE4 using `ForecastRow`, `WeatherView`, `ViewModelTests`, and `WeatherTests`. + +## Verification + +- Generate the Xcode project from `apps/swift/project.yml`. +- Build `PackRat-iOS` for an available iOS simulator through XcodeBuildMCP. +- Run the five `WeatherTemperatureDisplayTests`. +- Run the three focused UI tests when PackRat E2E credentials are available; otherwise record an explicit credential-based skip rather than claiming execution. diff --git a/docs/plans/2026-08-06-001-swift-launch-readiness-qa-plan.md b/docs/plans/2026-08-06-001-swift-launch-readiness-qa-plan.md new file mode 100644 index 0000000000..0c33ec446e --- /dev/null +++ b/docs/plans/2026-08-06-001-swift-launch-readiness-qa-plan.md @@ -0,0 +1,234 @@ +--- +title: Swift Launch Readiness QA - Plan +type: fix +date: 2026-08-06 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# Swift Launch Readiness QA - Plan + +## Goal Capsule + +- **Objective:** Bring the Swift iOS, iPad, macOS, and Watch surfaces to launch-readiness confidence as a seamless update path from the Expo app by syncing current branches, generating fresh screenshot evidence, proving Expo-to-Swift data compatibility, running full Swift QA, requesting Mikibo review, and fixing any launch-blocking defects found. +- **Authority:** Expo production behavior remains the parity baseline; open Swift/mobile GitHub issues and live screenshot evidence override stale prior confidence claims; native SwiftUI/AppKit expectations govern UI polish. +- **Execution profile:** Audit latest branches and PRs, preserve existing uncommitted Swift tester fixes, run screenshot and E2E coverage across Apple targets, inspect failures/screenshots manually, fix real bugs with focused tests, then commit/PR/watch CI. +- **Stop conditions:** Stop only for unavailable credentials/signing infrastructure, a failing local toolchain that cannot build Apple targets, or a product decision needed to choose between Expo parity and a new Swift-native behavior. +- **Tail ownership:** The implementation run owns branch hygiene, QA artifacts, screenshot contact sheets, fixes, regression tests, PR updates, and CI follow-through. + +--- + +## Product Contract + +### Summary + +The Swift app should be ready for tester distribution as the Apple-native successor to the Expo mobile experience, with no known launch-blocking gaps in core flows, auth/guest behavior, offline/local-first behavior, AI/weather/catalog/pack CRUD, visual polish, or platform packaging. + +### Problem Frame + +Prior Swift readiness work improved the app substantially, but repeated tester feedback showed that passing checks did not always mean screens were visually correct or deployed/TestFlight behavior was healthy. The current request is a launch-readiness reset: inspect the latest branches, generate fresh screenshots, run real QA, and keep fixing until the Swift variant is credible as a seamless update from Expo. + +### Requirements + +#### Branch and Ticket Reality + +- R1. The working branch is synced against the latest intended base branch and all relevant open Swift/TestFlight/mobile PRs and issues are reviewed before declaring readiness. +- R2. Existing uncommitted Swift tester-fix work is preserved, understood, and either incorporated into this readiness branch or deliberately isolated with a clear reason. +- R3. PR metadata and versioning must not contradict the app binaries, TestFlight replacement settings, or Expo baseline version. +- R3a. Mikibo review is explicitly requested on the active Swift launch PR before readiness is claimed. + +#### Screenshots and Manual Review + +- R4. Fresh screenshot catalogs exist for iPhone, iPad, macOS, and Watch where tooling supports them. +- R5. Screenshot catalogs include guest, authenticated, offline/error, populated, modal, menu, form, and feature-specific states needed to visually detect broken UI. +- R6. The implementation run manually reviews the screenshot outputs for obvious errors, stale data confusion, missing icons, bad padding, broken empty states, overflow, connection-state misuse, and non-native-looking controls. + +#### E2E and Functional Coverage + +- R7. Swift iOS and macOS smoke/full or equivalent UI E2E suites run against the local deterministic API where possible. +- R8. Core features are testable end to end: auth/guest entry, home navigation/search, packs/trips/items CRUD, catalog add flow, templates, weather, season suggestions, trail conditions, chat/AI fallback, settings/preferences, offline/local-first behavior, and platform-specific Watch sync smoke. +- R9. Tests must verify behavior rather than hiding defects through overly broad retries, weak assertions, or fixture states that mask real deployed failures. +- R9a. Shared API/schema fixtures that were produced by, or remain compatible with, the Expo app must decode and render in Swift without losing core user data such as packs, trips, pack items, catalog items, weights, locations, weather, and AI responses. + +#### Launch Readiness + +- R10. App Store/TestFlight replacement configuration is verified for bundle identifiers, display names, version/build numbers, icons, orientations, entitlements, and archive overrides. +- R11. Any issue found during QA is fixed with the smallest scoped change that follows existing SwiftUI patterns and includes regression coverage. +- R12. Readiness is reported honestly: green CI and screenshots are evidence, but remaining open tickets and untested deployed/TestFlight paths are called out. + +### Key Flows + +- F1. **Fresh branch audit** + - **Trigger:** Launch-readiness run starts. + - **Steps:** Fetch/prune remotes, inspect current branch, inspect open PRs/issues, compare branch against development/main as appropriate, and identify any stale or conflicting Swift work. + - **Outcome:** The active branch choice and unresolved ticket list are explicit. + - **Covered by:** R1, R2, R3. +- F2. **Screenshot-driven QA** + - **Trigger:** Swift builds and test runners are runnable. + - **Steps:** Generate screenshot catalogs for supported Apple platforms, inspect contact sheets and individual images, record defects, and fix visible regressions. + - **Outcome:** Screenshots are current review artifacts, not stale confidence theater. + - **Covered by:** R4, R5, R6, R11. +- F3. **Full functional QA** + - **Trigger:** Branch is synced and screenshot setup is understood. + - **Steps:** Run Swift scripts, unit tests, iOS/macOS E2E, Watch sync smoke, TestFlight preflight, and targeted tests for any changed flows. + - **Outcome:** Core flows have executable coverage and failures are triaged as real bugs or infrastructure blockers. + - **Covered by:** R7, R8, R9, R10. + +### Acceptance Examples + +- AE1. Given the current branch has stale or uncommitted Swift tester work, when launch QA begins, then that work is listed, preserved, and either validated or deliberately separated before commits are made. +- AE2. Given a fresh iPhone screenshot catalog, when a Home, form, modal, menu, Weather, Chat, Pack, Catalog, Settings, or offline state is visually broken, then a concrete issue is fixed or recorded before readiness is claimed. +- AE3. Given the Swift app is intended to replace the Expo listing, when TestFlight preflight runs, then replacement bundle IDs, version/build, archive overrides, and display metadata align with that release path. +- AE4. Given the local deterministic API is available, when iOS and macOS E2E flows run, then auth/guest and core CRUD/AI/weather flows are asserted with feature-specific expectations. +- AE5. Given some repo-wide tickets remain outside this Swift launch scope, when the final report is written, then they are named as residuals rather than implied solved. +- AE6. Given a real Expo-era API payload or shared-schema fixture, when Swift model decoding and screenshot data-state tests run, then the same user data appears in Swift UI states without replacement-bundle migration breakage. + +### Scope Boundaries + +- Expo implementation changes are out of scope unless they directly unblock Swift parity verification or shared API stubs. +- Broad API security tickets and unrelated web/landing issues are not launch blockers for this Swift readiness pass unless QA proves they affect Swift runtime behavior. +- Dummy data should support deterministic tests and screenshots only; it must not create confusing production user-facing defaults. +- A real TestFlight/device smoke is required for final release confidence, but this plan can only automate it when credentials, signing, and App Store Connect access are locally available. + +### Sources + +- Open PRs #2627 and #2653, plus any newer Swift/mobile PRs discovered during implementation. +- Existing plans `docs/plans/2026-07-17-001-fix-swift-testflight-loading-and-pr-readiness-plan.md` and `docs/plans/2026-07-23-001-fix-swift-tester-issues-plan.md`. +- Swift app sources under `apps/swift/Sources/PackRat`, Swift tests under `apps/swift/Tests`, and screenshot/E2E scripts under `apps/swift/scripts`. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. Use screenshot evidence as a release gate, not only test pass/fail. (session-settled: user-directed - chosen over test-only confidence: prior iterations missed visible error states and broken UI despite passing tests.) +- KTD2. Treat Expo as the parity baseline while allowing Swift-native UI improvements. (session-settled: user-directed - chosen over a standalone Swift redesign: the Swift app is intended to ship as a seamless update to the existing Expo app.) +- KTD3. Preserve and audit existing uncommitted tester fixes before editing. Uncommitted changes in this worktree are assumed to be user/prior-agent work and must not be overwritten or casually folded into unrelated commits. +- KTD4. Prefer deterministic local API and generated fixtures for E2E reliability, but do not weaken assertions or hide deployed/TestFlight bugs. Local stubs prove app behavior; deployed smoke remains a separate release gate. +- KTD5. Keep launch-readiness fixes small and SwiftUI-native. Reusable components are appropriate for repeated state/layout patterns, but unrelated refactors wait until after launch-readiness evidence is clean. + +### High-Level Technical Design + +```mermaid +flowchart TB + BranchAudit[Branch and issue audit] --> ExistingDiff[Classify existing Swift diff] + ExistingDiff --> Build[Generate project and build] + Build --> Tests[Run unit, scripts, iOS, macOS, Watch checks] + Tests --> Screens[Capture screenshots by platform and state] + Screens --> Review[Manual visual review] + Review --> Bugs{Launch blockers?} + Bugs -->|yes| Fix[Fix with regression tests] + Fix --> Tests + Bugs -->|no| Ship[Commit, PR update, CI watch] +``` + +### Assumptions + +- The intended integration base remains `development` unless branch/PR metadata shows the Swift launch branch has moved elsewhere. +- The local machine has Xcode, simulators, and Apple tooling installed, but signing/TestFlight upload may still require credentials or manual Apple account state. +- The current worktree may be stale relative to GitHub because older fetches can hang; implementation should verify remote state with targeted GitHub commands when needed. + +### Sequencing + +Start by making branch and diff state explicit, because existing uncommitted Swift changes change both QA scope and commit hygiene. Then run cheap script/unit checks before expensive simulator screenshot/E2E runs. Use screenshot review to drive targeted fixes, then rerun only the affected narrow tests before final broad gates. + +--- + +## Implementation Units + +### U1. Branch, PR, issue, and reviewer audit + +- **Goal:** Establish the current launch-readiness baseline and avoid working from stale branch assumptions. +- **Requirements:** R1, R2, R3, R3a; KTD3. +- **Files:** `docs/plans/2026-08-06-001-swift-launch-readiness-qa-plan.md`, PR metadata, issue metadata, and any branch notes added to the PR body. +- **Approach:** Inspect current branch, uncommitted diff, open PRs, recent merged Swift branches, and open Swift/mobile/TestFlight issues. Decide whether to continue on `codex/swift-beta-tester-readiness`, switch to another branch, or create a new branch from the latest base. +- **Test scenarios:** + 1. Covers AE1. Existing uncommitted files are listed before any edits. + 2. Covers R1. Open Swift/mobile PRs and issues are summarized with scope classification. + 3. Covers R3. Version/build metadata is checked against Expo and TestFlight scripts. + 4. Covers R3a. Mikibo review is requested on the active launch PR. +- **Verification:** Branch state and issue scope are included in the final report and, when code changes ship, the PR description. + +### U2. Swift build, script, and release metadata verification + +- **Goal:** Prove the Swift app can build and its release metadata supports a seamless Expo replacement. +- **Requirements:** R3, R7, R10. +- **Files:** `apps/swift/project.yml`, `apps/swift/Resources/Info-iOS.plist`, `apps/swift/Resources/Info-macOS.plist`, `apps/swift/Resources/Info-watchOS.plist`, `apps/swift/scripts/lib/testflight-config.ts`, `apps/swift/scripts/verify-testflight-replacement.ts`, and related tests. +- **Approach:** Regenerate Swift config/project if needed, run Swift script tests, validate assets, inspect build settings, and run TestFlight replacement preflight/dry-run where credentials allow. +- **Test scenarios:** + 1. Covers AE3. Replacement preflight reports the intended bundle IDs, version, build number, and archive overrides. + 2. Swift script tests cover argument parsing, config generation, TestFlight config, asset validation, and screenshot runner logic. + 3. Xcode build settings expose no stale `1.0`/wrong bundle ID/default display name for the intended replacement path. +- **Verification:** `bun test:swift:scripts`, `bun swift:validate-assets`, `bun swift:testflight:preflight --replacement --production`, and Xcode build/show-settings checks pass or produce explicit blockers. + +### U3. iOS, iPad, macOS, and Watch screenshot catalog + +- **Goal:** Produce fresh visual evidence across supported Apple surfaces and inspect it for launch blockers. +- **Requirements:** R4, R5, R6; KTD1. +- **Files:** `apps/swift/scripts/capture-visual-screenshots.ts`, `apps/swift/Tests/PackRatUITests/VisualScreenshotTests.swift`, `apps/swift/Tests/PackRatMacUITests`, `apps/swift/scripts/watch-sync-smoke.ts`, and `artifacts/screenshots*`. +- **Approach:** Run screenshot capture for iOS, iPad, macOS, and Watch as supported. Save individual images and contact sheets. Review outputs manually and record visible defects by screen/state. +- **Test scenarios:** + 1. Covers AE2. Catalog includes Home, auth/guest, settings, packs/trips, catalog, weather, chat, templates, offline states, forms, modals, and menus. + 2. Contact sheets do not show cropped, stale, notification-obscured, or incorrectly connected states unless intentionally captured. + 3. Watch screenshot/sync smoke demonstrates companion behavior or records a tooling/signing limitation. +- **Verification:** Fresh contact sheets exist and the final report names their paths and visual findings. + +### U4. Full Swift E2E and core feature QA + +- **Goal:** Exercise launch-critical behavior beyond unit tests. +- **Requirements:** R7, R8, R9, R9a. +- **Files:** `apps/swift/Tests/PackRatUITests`, `apps/swift/Tests/PackRatMacUITests`, `apps/swift/Tests/PackRatTests`, `apps/swift/scripts/run-e2e.ts`, `apps/swift/scripts/run-e2e-macos.ts`, and deterministic API routes under `packages/api/src`. +- **Approach:** Run iOS smoke/full or targeted equivalent, iPad-capable screenshot/UITest paths, macOS smoke/full or targeted equivalent, Swift unit tests, and Watch sync smoke. Where full runs are too slow or flaky, identify the exact failing test and rerun targeted after fixes. +- **Test scenarios:** + 1. Authenticated and guest flows both reach expected screens without false connection-required states. + 2. Packs/trips/items CRUD and catalog add flows work with deterministic test data. + 3. Weather, season suggestions, trail conditions, and chat/AI flows show data/fallback states correctly. + 4. Settings/preferences and offline/local-first paths survive relaunch and network-disabled conditions. + 5. Expo/shared-schema payloads decode through Swift models and render populated data-state screenshots without falling into empty/error placeholders. +- **Verification:** `bun test:swift:unit`, `bun e2e:swift:ios-smoke`, `bun e2e:swift:ios`, `bun e2e:swift:mac-smoke`, `bun e2e:swift:mac-ui`, and `bun swift:watch-sync-smoke` pass or residual blockers are filed. + +### U5. Fix launch-blocking defects with regression coverage + +- **Goal:** Convert QA findings into small, tested fixes without hiding real bugs. +- **Requirements:** R9, R11, R12; KTD4, KTD5. +- **Files:** Determined by findings, likely under `apps/swift/Sources/PackRat`, `apps/swift/Tests/PackRatTests`, `apps/swift/Tests/PackRatUITests`, `apps/swift/Tests/PackRatMacUITests`, and `packages/api/src/routes` for deterministic E2E stubs. +- **Approach:** For each defect, reproduce with a screenshot/test/log, apply the minimal SwiftUI-native fix, add or strengthen a regression assertion, and rerun the narrow gate plus the relevant broad gate. +- **Test scenarios:** + 1. A visible UI defect has before/after screenshot evidence. + 2. A functional defect has a failing or characterization test before the fix where practical. + 3. A residual limitation has a durable issue/PR note rather than being described as solved. +- **Verification:** Final diff contains only intentional fixes, tests pass, screenshots are refreshed after visual changes, and residuals are explicit. + +--- + +## Verification Contract + +| Gate | Applicability | Done signal | +|---|---|---| +| Branch hygiene | U1 | `git status --short --branch` is understood, with unrelated/user changes preserved | +| Swift script tests | U2 | `bun test:swift:scripts` passes | +| Asset and TestFlight metadata | U2 | `bun swift:validate-assets` and replacement preflight/dry-run pass or produce explicit credential blockers | +| Swift unit tests | U4, U5 | `bun test:swift:unit` passes | +| iOS E2E | U4, U5 | `bun e2e:swift:ios-smoke` and full or targeted iOS E2E pass | +| macOS E2E | U4, U5 | `bun e2e:swift:mac-smoke` and full or targeted macOS E2E pass | +| Screenshot catalog | U3, U5 | Fresh iOS, iPad, macOS, and Watch artifacts are generated where supported and manually reviewed | +| Watch sync smoke | U3, U4 | `bun swift:watch-sync-smoke` passes or a concrete watchOS tooling blocker is recorded | +| CI | All units | PR checks are green or non-Swift residuals are explicitly separated from Swift launch readiness | + +--- + +## Definition of Done + +- Latest branch, PR, and issue state has been checked and summarized. +- Mikibo review has been requested on the active Swift launch PR. +- Existing uncommitted Swift tester fixes are preserved and either shipped or separated. +- Fresh screenshots/contact sheets exist for supported iPhone, iPad, macOS, and Watch states. +- Screenshots have been manually reviewed and any visible launch blockers are fixed or filed. +- Expo/shared-schema data compatibility is verified by tests and visible populated Swift screenshots. +- Swift script, unit, iOS E2E, macOS E2E, and Watch smoke gates have passed or produced named blockers. +- TestFlight replacement metadata is consistent with the intended seamless Expo update path. +- Any code changes have focused regression coverage and do not weaken assertions to pass tests. +- PR/commit history clearly communicates what was fixed, what was validated, and what remains outside scope. diff --git a/docs/testing.md b/docs/testing.md index 77a1cbc73b..f2296e8e46 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -221,10 +221,10 @@ Test fixtures must seed users through `userService.createUser`. Do not write new ### Pattern 5 — Swift visual E2E catalog -The native Swift apps have a visual catalog runner that drives `VisualScreenshotTests` on iOS and macOS, exports every named screenshot, validates the required surface matrix, and renders contact sheets for review. +The native Swift apps have a visual catalog runner that drives `VisualScreenshotTests` on iOS, iPad, and macOS, captures the watchOS companion, exports every named screenshot, validates the required surface matrix, and renders contact sheets for review. ```bash -# Full iOS + macOS visual pass. Requires E2E credentials. +# Full iOS + iPad + macOS + watchOS visual pass. Requires E2E credentials. bun swift:screenshots --out artifacts/screenshots # Platform-specific runs while iterating. @@ -245,7 +245,7 @@ The runner writes: Screenshot fixture data must stay behind `PACKRAT_VISUAL_SCREENSHOTS` / `PACKRAT_VISUAL_SAMPLE_DATA` or explicit visual-test launch arguments. Production fallback and offline states should use honest empty or unsynced copy, not realistic dummy packs, trips, or weather that could be mistaken for user data. -CI runs the same catalog through `.github/workflows/swift-visual.yml` on a nightly schedule and by manual dispatch. The workflow uploads the contact sheets and visual `.xcresult` bundles as `swift-visual-screenshots`. macOS visual runs require Automation Mode to be available on the runner; locally, run `automationmodetool enable-automationmode-without-authentication` once before leaving the suite unattended. +CI runs the same catalog through `.github/workflows/swift-visual.yml` on a nightly schedule and by manual dispatch. The default `all` platform run covers iOS, iPad, macOS, and watchOS; `both` remains a legacy alias for the same full spread. The workflow uploads the contact sheets and visual `.xcresult` bundles as `swift-visual-screenshots`. watchOS screenshots are simulator-captured and do not produce an `.xcresult` bundle. macOS visual runs require Automation Mode to be available on the runner; locally, run `automationmodetool enable-automationmode-without-authentication` once before leaving the suite unattended. --- @@ -291,7 +291,7 @@ bun test:scripts # Swift native apps bun swift # regenerate the Xcode project after project.yml or source tree changes bun test:swift:scripts # TypeScript helper tests for simctl/xcresult/script parsing -bun swift:screenshots # visual E2E catalog for iOS + macOS +bun swift:screenshots # visual E2E catalog for iOS + iPad + macOS + watchOS ``` Coverage reports for each workspace: diff --git a/package.json b/package.json index b51dea02fd..c45d61186c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "packrat-monorepo", - "version": "2.1.0", + "version": "2.2.0", "workspaces": [ "apps/*", "packages/*" @@ -36,11 +36,12 @@ "check-types-watch": "tsc --noEmit --watch", "clean": "bun run .github/scripts/clean.ts", "e2e:swift": "bun run apps/swift/scripts/run-e2e.ts", - "e2e:swift:ios": "bun run apps/swift/scripts/run-e2e.ts ios-ui", - "e2e:swift:ios-smoke": "bun run apps/swift/scripts/run-e2e.ts ios-smoke", - "e2e:swift:mac": "bun run apps/swift/scripts/run-e2e.ts mac-build", - "e2e:swift:mac-smoke": "bun run apps/swift/scripts/run-e2e.ts mac-smoke", - "e2e:swift:mac-ui": "bun run apps/swift/scripts/run-e2e.ts mac-ui", + "e2e:swift:ios": "bun run apps/swift/scripts/run-e2e.ts --plan full", + "e2e:swift:ios-sanity": "bun run apps/swift/scripts/run-e2e.ts --plan sanity", + "e2e:swift:ios-smoke": "bun run apps/swift/scripts/run-e2e.ts --plan smoke", + "e2e:swift:mac": "bun run apps/swift/scripts/run-e2e-macos.ts --plan smoke", + "e2e:swift:mac-smoke": "bun run apps/swift/scripts/run-e2e-macos.ts --plan smoke", + "e2e:swift:mac-ui": "bun run apps/swift/scripts/run-e2e-macos.ts --plan full", "e2e:swift:macos": "bun run apps/swift/scripts/run-e2e-macos.ts", "env": "bun run .github/scripts/env.ts", "expo": "cd apps/expo && bun start", @@ -68,6 +69,7 @@ "swift:models": "bun run apps/swift/scripts/generate-swift-models.ts", "swift:quicktype": "bun run apps/swift/scripts/generate-quicktype-models.ts", "swift:screenshots": "bun run apps/swift/scripts/capture-visual-screenshots.ts", + "swift:testflight:preflight": "bun run apps/swift/scripts/verify-testflight-replacement.ts", "swift:validate-assets": "bun run apps/swift/scripts/validate-app-store-assets.ts", "swift:watch-sync-smoke": "bun run apps/swift/scripts/watch-sync-smoke.ts", "test:api:unit": "vitest run --config packages/api/vitest.unit.config.ts", @@ -80,7 +82,7 @@ "test:lint": "bun test scripts/lint/", "test:mcp": "bun run --cwd packages/mcp test", "test:scripts": "vitest run --config scripts/vitest.config.ts", - "test:swift:runner": "bun test apps/swift/scripts/run-e2e.test.ts", + "test:swift:runner": "vitest run --config apps/swift/vitest.config.ts apps/swift/scripts/__tests__/args.test.ts apps/swift/scripts/__tests__/macos-args.test.ts", "test:swift:scripts": "vitest run --config apps/swift/vitest.config.ts", "test:swift:unit": "bun run apps/swift/scripts/run-e2e.ts unit", "trails": "bun run --cwd apps/trails dev", diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 6a4c09d49f..f550f14c55 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/analytics", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "scripts": { diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 904a18ca64..1cd08c3a43 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/api-client", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/api/container_src/package.json b/packages/api/container_src/package.json index ab961d5d69..7987df76b4 100644 --- a/packages/api/container_src/package.json +++ b/packages/api/container_src/package.json @@ -1,6 +1,6 @@ { "name": "container", - "version": "2.1.0", + "version": "2.2.0", "type": "module", "dependencies": { "@aws-sdk/client-s3": "^3.0.0", diff --git a/packages/api/migrate.ts b/packages/api/migrate.ts index f2e364aa68..5e4f28461a 100644 --- a/packages/api/migrate.ts +++ b/packages/api/migrate.ts @@ -1,13 +1,14 @@ +import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import { neon, neonConfig } from '@neondatabase/serverless'; import { nodeEnv } from '@packrat/env/node'; +import { safeJsonParse } from '@packrat/utils'; +import { drizzle as drizzleBunSQL } from 'drizzle-orm/bun-sql'; +import { migrate as migrateBunSQL } from 'drizzle-orm/bun-sql/migrator'; import { drizzle } from 'drizzle-orm/neon-http'; import { migrate } from 'drizzle-orm/neon-http/migrator'; -import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; -import { migrate as migratePg } from 'drizzle-orm/node-postgres/migrator'; -import { Client } from 'pg'; import WebSocket from 'ws'; // Required for Neon serverless driver to work in Node.js @@ -17,6 +18,7 @@ neonConfig.webSocketConstructor = WebSocket; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const STANDARD_POSTGRES_MIGRATION_ATTEMPTS = 3; +const STANDARD_POSTGRES_MIGRATION_SETTLE_TIMEOUT_MS = 120_000; // Check if we're using a standard PostgreSQL URL (for tests) vs Neon URL // Import the utility function from src/db/index.ts since it's defined there @@ -35,21 +37,71 @@ const isStandardPostgresUrl = (url: string) => { } }; +function expectedMigrationCount() { + const journal = safeJsonParse( + readFileSync(join(__dirname, 'drizzle/meta/_journal.json'), 'utf8'), + ) as { + entries?: unknown[]; + }; + return journal.entries?.length ?? 0; +} + +async function appliedMigrationCount(url: string) { + const sql = new Bun.SQL(url); + try { + const rows = await sql<{ count: number }[]>` + select count(*)::int as count + from drizzle.__drizzle_migrations + `; + return Number(rows[0]?.count ?? 0); + } catch { + return 0; + } finally { + await sql.close().catch(() => undefined); + } +} + +async function waitForPostgresMigrationLedger(input: { + url: string; + expectedCount: number; + timeoutMs: number; +}) { + const startedAt = Date.now(); + while (Date.now() - startedAt < input.timeoutMs) { + if ((await appliedMigrationCount(input.url)) >= input.expectedCount) return; + await sleep(1000); + } + throw new Error( + `PostgreSQL migration ledger did not reach ${input.expectedCount} entries within ${ + input.timeoutMs / 1000 + }s.`, + ); +} + async function runPostgresMigrations(url: string) { let lastError: unknown; + const expectedCount = expectedMigrationCount(); for (let attempt = 1; attempt <= STANDARD_POSTGRES_MIGRATION_ATTEMPTS; attempt += 1) { - const client = new Client({ connectionString: url }); + const sql = new Bun.SQL(url); try { - await client.connect(); - const db = drizzlePg(client); - await migratePg(db, { migrationsFolder: join(__dirname, 'drizzle') }); - await client.end(); + const db = drizzleBunSQL(sql); + const migration = migrateBunSQL(db, { migrationsFolder: join(__dirname, 'drizzle') }); + migration.catch(() => undefined); + await Promise.race([ + migration, + waitForPostgresMigrationLedger({ + url, + expectedCount, + timeoutMs: STANDARD_POSTGRES_MIGRATION_SETTLE_TIMEOUT_MS, + }), + ]); + await sql.close(); return; } catch (error) { lastError = error; - await client.end().catch(() => undefined); + await sql.close().catch(() => undefined); const message = error instanceof Error ? error.message : String(error); if (attempt === STANDARD_POSTGRES_MIGRATION_ATTEMPTS) { diff --git a/packages/api/package.json b/packages/api/package.json index b3ae15afe8..49a0640e92 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/api", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/api/scripts/e2e-local-start.ts b/packages/api/scripts/e2e-local-start.ts index 1d81abdcbc..8a992e1f54 100644 --- a/packages/api/scripts/e2e-local-start.ts +++ b/packages/api/scripts/e2e-local-start.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { nodeEnv } from '@packrat/env/node'; @@ -18,16 +18,29 @@ const composeEnv = { E2E_DB_PORT: e2eDbPort, }; -async function run(opts: { command: string[]; cwd?: string; env?: NodeJS.ProcessEnv }) { +async function run(opts: { + command: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + captureOutput?: boolean; +}) { const { command } = opts; const child = Bun.spawn(command, { cwd: opts.cwd, env: opts.env ?? composeEnv, - stdout: 'inherit', - stderr: 'inherit', + stdout: opts.captureOutput ? 'pipe' : 'inherit', + stderr: opts.captureOutput ? 'pipe' : 'inherit', }); + const [stdout, stderr] = opts.captureOutput + ? await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]) + : ['', '']; const exitCode = await child.exited; + if (opts.captureOutput) { + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + } if (exitCode !== 0) throw new Error(`${command.join(' ')} exited with ${exitCode}`); + return `${stdout}${stderr}`; } async function succeeds(command: string[]) { @@ -59,6 +72,19 @@ function parseEnvFile(text: string) { return vars; } +function upsertEnvFileValue(input: { path: string; key: string; value: string }) { + const { path, key, value } = input; + const lines = readFileSync(path, 'utf8').replaceAll('\r\n', '\n').split('\n'); + let updated = false; + const nextLines = lines.map((line) => { + if (!line.trim().startsWith(`${key}=`)) return line; + updated = true; + return `${key}=${value}`; + }); + if (!updated) nextLines.push(`${key}=${value}`); + writeFileSync(path, nextLines.join('\n')); +} + if (!(await succeeds(['docker', '--version']))) { console.error('Error: Docker not found. Install Docker Desktop and try again.'); process.exit(1); @@ -119,7 +145,7 @@ const e2eEmail = nodeEnv.E2E_TEST_EMAIL ?? envFileVars.E2E_TEST_EMAIL ?? 'e2e@pa const e2ePass = nodeEnv.E2E_TEST_PASSWORD ?? envFileVars.E2E_TEST_PASSWORD ?? 'E2eTestPass123!'; console.log(`Seeding E2E test user (${e2eEmail})...`); -await run({ +const seedOutput = await run({ command: ['bun', 'run', 'db:seed:e2e-user'], cwd: apiDir, env: { @@ -128,7 +154,13 @@ await run({ E2E_TEST_EMAIL: e2eEmail, E2E_TEST_PASSWORD: e2ePass, }, + captureOutput: true, }); +const seededUserId = seedOutput.split('(id=').at(1)?.split(')').at(0); +if (!seededUserId) { + throw new Error('Unable to resolve seeded E2E user id from db:seed:e2e-user output.'); +} +upsertEnvFileValue({ path: e2eVars, key: 'E2E_TEST_USER_ID', value: seededUserId }); console.log(''); console.log(`Starting local E2E API on http://localhost:${apiPort} ...`); @@ -143,7 +175,16 @@ const apiEnv = { NODE_ENV: 'test', NEON_DATABASE_URL: e2eDbUrl, NEON_DATABASE_URL_READONLY: e2eDbUrl, + E2E_TEST_EMAIL: e2eEmail, + E2E_TEST_PASSWORD: e2ePass, + E2E_TEST_USER_ID: seededUserId, BETTER_AUTH_URL: `http://127.0.0.1:${apiPort}`, + OPENAI_API_KEY: + nodeEnv.OPENAI_API_KEY ?? + (envFileVars.OPENAI_API_KEY === 'sk-test' ? 'sk-e2e-stub-local' : envFileVars.OPENAI_API_KEY), + WEATHER_API_KEY: nodeEnv.WEATHER_API_KEY ?? 'weather-e2e-stub-local', + APPLE_PRIVATE_KEY: + nodeEnv.APPLE_PRIVATE_KEY ?? envFileVars.APPLE_PRIVATE_KEY ?? 'e2e-apple-private-key', PACKRAT_PG_POOL_MAX: nodeEnv.PACKRAT_PG_POOL_MAX ?? envFileVars.PACKRAT_PG_POOL_MAX ?? '50', }; diff --git a/packages/api/scripts/e2e-node-server.ts b/packages/api/scripts/e2e-node-server.ts index 0516cf8927..a69cc27708 100644 --- a/packages/api/scripts/e2e-node-server.ts +++ b/packages/api/scripts/e2e-node-server.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; +import { preloadDbDrivers } from '@packrat/api/db'; import { nodeEnv } from '@packrat/env/node'; import { Miniflare } from 'miniflare'; -import worker from '../src/e2e-worker'; const port = Number(nodeEnv.PORT ?? 8787); @@ -10,6 +10,7 @@ const kvPersist = const noop = async () => {}; +console.log(`Preparing PackRat e2e API on port ${port}...`); const miniflare = new Miniflare({ script: 'export default { fetch() { return new Response("ok") } }', modules: true, @@ -17,7 +18,9 @@ const miniflare = new Miniflare({ kvPersist, logRequests: false, }); +console.log('Loading PackRat e2e AUTH_KV namespace...'); const authKv = await miniflare.getKVNamespace('AUTH_KV'); +console.log('PackRat e2e AUTH_KV namespace loaded.'); const bucket = { get: async () => null, @@ -54,6 +57,14 @@ const ctx = { passThroughOnException: () => {}, }; +console.log('Preloading PackRat e2e DB drivers...'); +await preloadDbDrivers(); +console.log('PackRat e2e DB drivers loaded.'); + +console.log('Loading PackRat e2e worker...'); +const { default: worker } = await import('../src/e2e-worker'); +console.log('PackRat e2e worker loaded.'); + Bun.serve({ port, hostname: '0.0.0.0', diff --git a/packages/api/src/__tests__/auth-cors.test.ts b/packages/api/src/__tests__/auth-cors.test.ts index d50518fa03..bab969b02a 100644 --- a/packages/api/src/__tests__/auth-cors.test.ts +++ b/packages/api/src/__tests__/auth-cors.test.ts @@ -1,3 +1,7 @@ +import { + oauthProviderAuthServerMetadata, + oauthProviderOpenIdConfigMetadata, +} from '@better-auth/oauth-provider'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { @@ -111,6 +115,12 @@ const ctx = { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(oauthProviderAuthServerMetadata).mockReturnValue( + vi.fn(async () => Response.json({ issuer: 'http://localhost:8787/api/auth' })), + ); + vi.mocked(oauthProviderOpenIdConfigMetadata).mockReturnValue( + vi.fn(async () => Response.json({ issuer: 'http://localhost:8787/api/auth' })), + ); }); describe('Worker /api/auth CORS', () => { @@ -140,4 +150,30 @@ describe('Worker /api/auth CORS', () => { expect(res.headers.get('Access-Control-Allow-Methods')).toContain('POST'); expect(mocks.getAuth).not.toHaveBeenCalled(); }); + + it('serves auth-mounted OAuth metadata for Better Auth discovery', async () => { + const fetch = worker.fetch; + if (!fetch) throw new Error('Worker fetch handler is not configured'); + + const request = new Request( + 'http://localhost:8787/.well-known/oauth-authorization-server/api/auth', + { + method: 'GET', + headers: { Origin: 'http://localhost:5173' }, + }, + ); + + const res = await fetch( + // safe-cast: Cloudflare's typed Request carries cf metadata; this path only reads URL, method, and headers. + request as never, + { ENVIRONMENT: 'development' } as never, + ctx as never, + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ issuer: 'http://localhost:8787/api/auth' }); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://localhost:5173'); + expect(oauthProviderAuthServerMetadata).toHaveBeenCalled(); + expect(mocks.getAuth).toHaveBeenCalled(); + }); }); diff --git a/packages/api/src/__tests__/e2e-worker-env.test.ts b/packages/api/src/__tests__/e2e-worker-env.test.ts new file mode 100644 index 0000000000..0381692f5a --- /dev/null +++ b/packages/api/src/__tests__/e2e-worker-env.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + addCorsHeaders: vi.fn(({ response }) => response), + appFetch: vi.fn(async () => new Response('app')), + authHandler: vi.fn(async () => new Response('auth')), + corsPreflightResponse: vi.fn(() => null), + getAuth: vi.fn(), + getEnv: vi.fn(), + setWorkerEnv: vi.fn(), +})); + +vi.mock('@packrat/api/app', () => ({ + addCorsHeaders: mocks.addCorsHeaders, + appBase: { fetch: mocks.appFetch }, + corsPreflightResponse: mocks.corsPreflightResponse, +})); + +vi.mock('@packrat/api/auth', () => ({ + getAuth: mocks.getAuth, +})); + +vi.mock('@packrat/api/utils/env-validation', () => ({ + getEnv: mocks.getEnv, + setWorkerEnv: mocks.setWorkerEnv, +})); + +vi.mock('@better-auth/oauth-provider', () => ({ + oauthProviderAuthServerMetadata: vi.fn(() => mocks.authHandler), + oauthProviderOpenIdConfigMetadata: vi.fn(() => mocks.authHandler), +})); + +const worker = (await import('../e2e-worker')).default; + +const rawEnv = { + BETTER_AUTH_SECRET: 'e2e-better-auth-secret-at-least-32-chars', + BETTER_AUTH_URL: 'http://localhost:8787', + NODE_ENV: 'test', + OSM_HYPERDRIVE: { connectionString: 'postgres://user:pass@localhost/osm' }, +}; + +const validatedEnv = { + ...rawEnv, + OSM_DATABASE_URL: 'postgres://user:pass@localhost/osm', + PACKRAT_API_URL: 'http://localhost:8787', + PACKRAT_AUTH_SECRET: 'e2e-better-auth-secret-at-least-32-chars', +}; + +const ctx = { + passThroughOnException: vi.fn(), + waitUntil: vi.fn(), +} as unknown as ExecutionContext; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getEnv.mockReturnValue(validatedEnv); + mocks.getAuth.mockResolvedValue({ handler: mocks.authHandler }); +}); + +describe('e2e worker env normalization', () => { + it('normalizes env before Better Auth handles auth requests', async () => { + const request = new Request('http://localhost:8787/api/auth/sign-in/email', { + method: 'POST', + }); + + const response = await worker.fetch(request, rawEnv as never, ctx); + + expect(await response.text()).toBe('auth'); + expect(mocks.getEnv).toHaveBeenCalledWith({ + ...rawEnv, + OSM_DATABASE_URL: 'postgres://user:pass@localhost/osm', + }); + expect(mocks.setWorkerEnv).toHaveBeenCalledWith(validatedEnv); + expect(mocks.getAuth).toHaveBeenCalledWith(validatedEnv); + expect(mocks.authHandler).toHaveBeenCalledWith(request); + expect(mocks.addCorsHeaders).toHaveBeenCalledWith({ request, response }); + }); + + it('passes the normalized env to app routes', async () => { + const request = new Request('http://localhost:8787/api/packs'); + + const response = await worker.fetch(request, rawEnv as never, ctx); + + expect(await response.text()).toBe('app'); + expect(mocks.appFetch).toHaveBeenCalledWith(request, validatedEnv, ctx); + }); + + it('serves auth-mounted OAuth metadata with normalized env', async () => { + mocks.authHandler.mockResolvedValueOnce( + Response.json({ issuer: 'http://localhost:8787/api/auth' }), + ); + const request = new Request( + 'http://localhost:8787/.well-known/oauth-authorization-server/api/auth', + ); + + const response = await worker.fetch(request, rawEnv as never, ctx); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + issuer: 'http://localhost:8787/api/auth', + }); + expect(mocks.getAuth).toHaveBeenCalledWith(validatedEnv); + expect(mocks.authHandler).toHaveBeenCalledWith(request); + expect(mocks.appFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/auth/__tests__/mcp-token.test.ts b/packages/api/src/auth/__tests__/mcp-token.test.ts index ab11cb174d..d2c556761e 100644 --- a/packages/api/src/auth/__tests__/mcp-token.test.ts +++ b/packages/api/src/auth/__tests__/mcp-token.test.ts @@ -25,6 +25,7 @@ vi.mock('@packrat/db', () => ({ vi.mock('drizzle-orm', () => ({ eq: mocks.eq, + relations: vi.fn(() => ({})), })); vi.mock('jose', () => ({ diff --git a/packages/api/src/auth/__tests__/oauth-provider.test.ts b/packages/api/src/auth/__tests__/oauth-provider.test.ts index b6e0bffc37..8215bd23ed 100644 --- a/packages/api/src/auth/__tests__/oauth-provider.test.ts +++ b/packages/api/src/auth/__tests__/oauth-provider.test.ts @@ -14,7 +14,7 @@ * which the unit-test pool can't provide. */ -import { oauthAccessToken, oauthClient, oauthConsent, oauthRefreshToken } from '@packrat/db'; +import { oauthAccessToken, oauthClient, oauthConsent, oauthRefreshToken } from '@packrat/db/schema'; import { describe, expect, it } from 'vitest'; describe('OAuth provider schema (@packrat/db)', () => { diff --git a/packages/api/src/auth/auth.config.ts b/packages/api/src/auth/auth.config.ts index 961eade055..e1740c5b5c 100644 --- a/packages/api/src/auth/auth.config.ts +++ b/packages/api/src/auth/auth.config.ts @@ -13,7 +13,7 @@ import { drizzleAdapter } from '@better-auth/drizzle-adapter'; import { oauthProvider } from '@better-auth/oauth-provider'; import { neon } from '@neondatabase/serverless'; -import * as schema from '@packrat/db'; +import * as schema from '@packrat/db/schema'; import { type BetterAuthPlugin, betterAuth } from 'better-auth'; import { admin, bearer, jwt } from 'better-auth/plugins'; import { drizzle } from 'drizzle-orm/neon-http'; diff --git a/packages/api/src/auth/consent-route.ts b/packages/api/src/auth/consent-route.ts index 7c25ed0e0a..62a57309dd 100644 --- a/packages/api/src/auth/consent-route.ts +++ b/packages/api/src/auth/consent-route.ts @@ -26,7 +26,7 @@ import { getAuth } from '@packrat/api/auth'; import { createDb } from '@packrat/api/db'; import { getEnv } from '@packrat/api/utils/env-validation'; import { type OAuthClientRecord, renderConsentPage, renderSignInPage } from '@packrat/consent-ui'; -import * as dbSchema from '@packrat/db'; +import * as dbSchema from '@packrat/db/schema'; import { isString, toRecord, toString as toStr } from '@packrat/guards'; import { eq } from 'drizzle-orm'; import { Elysia } from 'elysia'; diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index b200f08cde..1818eaf436 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -13,7 +13,7 @@ import { oauthProvider } from '@better-auth/oauth-provider'; import { generateAppleClientSecret, verifyPasswordCompat } from '@packrat/api/auth/auth.helpers'; import { createConnection } from '@packrat/api/db'; import type { ValidatedEnv } from '@packrat/api/utils/env-validation'; -import * as schema from '@packrat/db'; +import * as schema from '@packrat/db/schema'; import { isObject } from '@packrat/guards'; import { safeJsonParse } from '@packrat/utils'; import { type BetterAuthPlugin, betterAuth } from 'better-auth'; diff --git a/packages/api/src/auth/local-e2e.ts b/packages/api/src/auth/local-e2e.ts index a3afb24599..dd2e5168ce 100644 --- a/packages/api/src/auth/local-e2e.ts +++ b/packages/api/src/auth/local-e2e.ts @@ -1,4 +1,5 @@ import type { ValidatedEnv } from '@packrat/api/utils/env-validation'; +import { isString } from '@packrat/guards'; const bearerPrefixRegex = /^Bearer\s+/i; @@ -19,6 +20,7 @@ export type LocalE2EUser = { export function isLocalE2EAuthEnabled(env: ValidatedEnv): boolean { const dbUrl = env.NEON_DATABASE_URL; return ( + isString(dbUrl) && (dbUrl.includes('127.0.0.1') || dbUrl.includes('localhost')) && Boolean(env.E2E_TEST_EMAIL) && Boolean(env.E2E_TEST_PASSWORD) && diff --git a/packages/api/src/auth/mcp-token.ts b/packages/api/src/auth/mcp-token.ts index 1b4af925c6..2ff745553f 100644 --- a/packages/api/src/auth/mcp-token.ts +++ b/packages/api/src/auth/mcp-token.ts @@ -1,7 +1,7 @@ import { createDb } from '@packrat/api/db'; import type { AuthUser } from '@packrat/api/middleware/auth'; import type { ValidatedEnv } from '@packrat/api/utils/env-validation'; -import { users } from '@packrat/db'; +import { users } from '@packrat/db/schema'; import { isString } from '@packrat/guards'; import { eq } from 'drizzle-orm'; import { createRemoteJWKSet, jwtVerify } from 'jose'; diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index ff0aac91b8..e07ff8b9a0 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -12,8 +12,6 @@ import * as schema from '@packrat/db/schema'; import { isFunction, isString } from '@packrat/guards'; import { drizzle } from 'drizzle-orm/neon-http'; import { drizzle as drizzleServerless } from 'drizzle-orm/neon-serverless'; -import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; -import { Pool } from 'pg'; const runtimeEnv = () => (globalThis as { process?: { env?: Record } }).process?.env ?? {}; @@ -41,11 +39,23 @@ const isStandardPostgresUrl = (url: string) => { } }; -const pgPools = new Map(); +const bunSqlClients = new Map(); +type DrizzleBunSql = typeof import('drizzle-orm/bun-sql')['drizzle']; +let cachedDrizzleBunSQL: DrizzleBunSql | undefined; -const getPgPoolMax = () => { - const parsed = Number(runtimeEnv().PACKRAT_PG_POOL_MAX); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 5; +export const preloadDbDrivers = async () => { + if (!cachedDrizzleBunSQL && 'Bun' in globalThis) { + cachedDrizzleBunSQL = (await import('drizzle-orm/bun-sql')).drizzle; + } +}; + +const loadDrizzleBunSQL = (): DrizzleBunSql => { + if (!cachedDrizzleBunSQL) { + throw new Error( + 'Bun SQL driver was not preloaded before creating a local Postgres connection.', + ); + } + return cachedDrizzleBunSQL; }; const shouldUseNeonWsProxy = (url: string) => { @@ -145,24 +155,16 @@ export const createConnection = ({ url, useNeonHttp }: { url: string; useNeonHtt return withTagging(drizzleServerless(neonPool, { schema })); } - let pool = pgPools.get(url); - if (!pool) { - const newPool = new Pool({ - connectionString: url, - max: getPgPoolMax(), - // idleTimeoutMillis: 0 prevents pg.Pool from calling setTimeout().unref(), - // which is not supported in the Cloudflare Workers runtime (miniflare). - idleTimeoutMillis: 0, - connectionTimeoutMillis: 10000, - }); - newPool.on('error', () => { - pgPools.delete(url); - }); - pgPools.set(url, newPool); - pool = newPool; + if (!('Bun' in globalThis) || !Bun.SQL) { + throw new Error('Standard PostgreSQL URLs require the Bun SQL runtime.'); + } + + let sql = bunSqlClients.get(url); + if (!sql) { + sql = new Bun.SQL(url); + bunSqlClients.set(url, sql); } - instrumentPool(pool); - return withTagging(drizzlePg(pool, { schema })); + return withTagging(loadDrizzleBunSQL()(sql, { schema })); } if (useNeonHttp) { const fn = instrumentNeonFn(neon(url)); diff --git a/packages/api/src/db/queryRows.ts b/packages/api/src/db/queryRows.ts new file mode 100644 index 0000000000..6b66e469bf --- /dev/null +++ b/packages/api/src/db/queryRows.ts @@ -0,0 +1,10 @@ +export type RawSqlResult = T[] | { rows?: T[] | null } | null | undefined; + +export function queryRows(result: RawSqlResult): T[] { + if (Array.isArray(result)) return result; + return result?.rows ?? []; +} + +export function firstQueryRow(result: RawSqlResult): T | undefined { + return queryRows(result)[0]; +} diff --git a/packages/api/src/db/seed-e2e-user.ts b/packages/api/src/db/seed-e2e-user.ts index 4b288b35ba..b94fcdc8ec 100644 --- a/packages/api/src/db/seed-e2e-user.ts +++ b/packages/api/src/db/seed-e2e-user.ts @@ -6,22 +6,17 @@ * bun run packages/api/src/db/seed-e2e-user.ts * * Re-running is safe: if the user exists, the password hash and - * `emailVerified=true` flag are refreshed via `db.update` (drizzle-seed - * has no UPDATE primitive); otherwise the user is created via the - * `drizzle-seed` `.refine()` API so this seeder shares the same tool - * surface as the other prod-config seeders. Every column is fixed via - * `f.default()` because drizzle-seed generates a random value for any - * column not listed in `.refine()`. + * `emailVerified=true` flag are refreshed via `db.update`; otherwise the + * user is created via a direct insert so this script avoids package-export + * differences in local E2E runtimes. */ import { neon, neonConfig } from '@neondatabase/serverless'; import * as schema from '@packrat/db/schema'; import { nodeEnv } from '@packrat/env/node'; import { eq } from 'drizzle-orm'; +import { type BunSQLDatabase, drizzle as drizzleBunSQL } from 'drizzle-orm/bun-sql'; import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http'; -import { drizzle as drizzlePg, type NodePgDatabase } from 'drizzle-orm/node-postgres'; -import { seed } from 'drizzle-seed'; -import { Client } from 'pg'; import WebSocket from 'ws'; import { hashPassword } from '../utils/auth'; @@ -50,14 +45,13 @@ async function seedE2EUser(): Promise { const normalizedEmail = email.toLowerCase(); - type SeedDatabase = NodePgDatabase | NeonHttpDatabase; + type SeedDatabase = BunSQLDatabase | NeonHttpDatabase; let db: SeedDatabase; - let pgClient: Client | undefined; + let sql: Bun.SQL | undefined; if (isStandardPostgresUrl(dbUrl)) { - pgClient = new Client({ connectionString: dbUrl }); - await pgClient.connect(); - db = drizzlePg(pgClient, { schema }); + sql = new Bun.SQL(dbUrl); + db = drizzleBunSQL(sql, { schema }); } else { db = drizzle(neon(dbUrl), { schema }); } @@ -84,28 +78,19 @@ async function seedE2EUser(): Promise { } else { userId = crypto.randomUUID(); const now = new Date(); - await seed(db, { users: schema.users }).refine((f) => ({ - users: { - count: 1, - columns: { - id: f.default({ defaultValue: userId }), - name: f.default({ defaultValue: 'E2E Automation' }), - email: f.default({ defaultValue: normalizedEmail }), - emailVerified: f.default({ defaultValue: true }), - image: f.default({ defaultValue: null }), - role: f.default({ defaultValue: 'USER' }), - banned: f.default({ defaultValue: false }), - banReason: f.default({ defaultValue: null }), - banExpires: f.default({ defaultValue: null }), - firstName: f.default({ defaultValue: 'E2E' }), - lastName: f.default({ defaultValue: 'Automation' }), - avatarUrl: f.default({ defaultValue: null }), - passwordHash: f.default({ defaultValue: passwordHash }), - createdAt: f.default({ defaultValue: now }), - updatedAt: f.default({ defaultValue: now }), - }, - }, - })); + await db.insert(schema.users).values({ + id: userId, + name: 'E2E Automation', + email: normalizedEmail, + emailVerified: true, + role: 'USER', + banned: false, + firstName: 'E2E', + lastName: 'Automation', + passwordHash, + createdAt: now, + updatedAt: now, + }); console.log(`E2E user created: ${normalizedEmail} (id=${userId})`); } @@ -113,8 +98,6 @@ async function seedE2EUser(): Promise { // Upsert the credential account row that better-auth looks up during sign-in. // better-auth sets accountId = user.id for the 'credential' provider. - // (drizzle-seed has no upsert; this requires onConflictDoUpdate so we use - // db.insert directly here rather than drizzle-seed's refine path.) await db .insert(schema.account) .values({ @@ -189,7 +172,7 @@ async function seedE2EUser(): Promise { .onConflictDoNothing({ target: schema.catalogItems.sku }); console.log('E2E catalog fixtures ensured'); } finally { - await pgClient?.end(); + await sql?.close(); } } diff --git a/packages/api/src/e2e-worker.ts b/packages/api/src/e2e-worker.ts index 73259dae73..8ccd1dcc68 100644 --- a/packages/api/src/e2e-worker.ts +++ b/packages/api/src/e2e-worker.ts @@ -1,8 +1,28 @@ import type { MessageBatch } from '@cloudflare/workers-types'; import { addCorsHeaders, appBase, corsPreflightResponse } from '@packrat/api/app'; -import { getAuth } from '@packrat/api/auth'; -import type { Env } from '@packrat/api/utils/env-validation'; -import { setWorkerEnv } from '@packrat/api/utils/env-validation'; +import { + isLocalE2EAuthEnabled, + localE2EToken, + makeLocalE2EUser, +} from '@packrat/api/auth/local-e2e'; +import { type Env, getEnv, setWorkerEnv } from '@packrat/api/utils/env-validation'; + +const WELL_KNOWN_AUTH_SERVER_PATH = '/.well-known/oauth-authorization-server'; +const WELL_KNOWN_OPENID_CONFIG_PATH = '/.well-known/openid-configuration'; +const WELL_KNOWN_AUTH_BASE_PATH = '/api/auth'; +const bearerPrefixRegex = /^Bearer\s+/i; + +async function loadAuth() { + const { getAuth } = await import('@packrat/api/auth'); + return getAuth; +} + +async function loadOAuthMetadataHandlers() { + const { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } = await import( + '@better-auth/oauth-provider' + ); + return { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata }; +} function enrichEnv(env: Env): Env { if (env.OSM_HYPERDRIVE) { @@ -11,17 +31,95 @@ function enrichEnv(env: Env): Env { return env; } +function envRecord(env: Env): Record { + return Object.fromEntries(Object.entries(env)); +} + +function wellKnownMetadataKind(pathname: string): 'openid' | 'authorization-server' | null { + if ( + pathname === WELL_KNOWN_OPENID_CONFIG_PATH || + pathname === `${WELL_KNOWN_OPENID_CONFIG_PATH}${WELL_KNOWN_AUTH_BASE_PATH}` + ) { + return 'openid'; + } + if ( + pathname === WELL_KNOWN_AUTH_SERVER_PATH || + pathname === `${WELL_KNOWN_AUTH_SERVER_PATH}${WELL_KNOWN_AUTH_BASE_PATH}` + ) { + return 'authorization-server'; + } + return null; +} + +async function handleLocalE2EAuth(input: { + request: Request; + env: Env; +}): Promise { + const { request, env } = input; + if (!isLocalE2EAuthEnabled(env)) return undefined; + + const url = new URL(request.url); + if (request.method === 'POST' && url.pathname === '/api/auth/sign-in/email') { + const body = (await request.json().catch(() => undefined)) as + | { email?: string; password?: string } + | undefined; + const email = body?.email?.toLowerCase(); + if (email !== env.E2E_TEST_EMAIL?.toLowerCase() || body?.password !== env.E2E_TEST_PASSWORD) { + return Response.json({ error: 'Invalid email or password' }, { status: 401 }); + } + + const token = await localE2EToken(env); + return Response.json( + { + redirect: false, + token, + user: makeLocalE2EUser(env), + }, + { headers: { 'set-auth-token': token } }, + ); + } + + if (request.method === 'POST' && url.pathname === '/api/auth/sign-out') { + const expected = await localE2EToken(env); + const authorization = request.headers.get('Authorization') ?? ''; + if (authorization.replace(bearerPrefixRegex, '') === expected) { + return Response.json({ success: true }); + } + } + + return undefined; +} + export default { // biome-ignore lint/complexity/useMaxParams: Cloudflare Worker fetch callbacks receive request, env, and context. async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - const e = enrichEnv(env); - setWorkerEnv(Object.assign({}, e)); + const e = getEnv(envRecord(enrichEnv(env))); + setWorkerEnv(envRecord(e)); const url = new URL(request.url); + if (request.method === 'GET') { + const metadataKind = wellKnownMetadataKind(url.pathname); + if (metadataKind) { + const getAuth = await loadAuth(); + const auth = await getAuth(e); + const { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } = + await loadOAuthMetadataHandlers(); + const handler = + metadataKind === 'openid' + ? oauthProviderOpenIdConfigMetadata(auth) + : oauthProviderAuthServerMetadata(auth); + return handler(request); + } + } + if (url.pathname.startsWith('/api/auth')) { const preflight = corsPreflightResponse(request); if (preflight) return preflight; + const localAuthResponse = await handleLocalE2EAuth({ request, env: e }); + if (localAuthResponse) return addCorsHeaders({ request, response: localAuthResponse }); + + const getAuth = await loadAuth(); const auth = await getAuth(e); return addCorsHeaders({ request, response: await auth.handler(request) }); } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 58eddaa50c..1f63519b7d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -36,7 +36,7 @@ import { import { captureApiException, record } from '@packrat/api/utils/sentry'; import { CatalogEtlWorkflow as RawCatalogEtlWorkflow } from '@packrat/api/workflows/catalog-etl-workflow'; import { renderConsentPage, renderSignInPage } from '@packrat/consent-ui'; -import * as dbSchema from '@packrat/db'; +import * as dbSchema from '@packrat/db/schema'; import { isString, toRecord, toString as toStr } from '@packrat/guards'; import { safeJsonParse, safeJsonStringify } from '@packrat/utils'; import { instrumentWorkflowWithSentry, withSentry } from '@sentry/cloudflare'; @@ -49,6 +49,9 @@ const bearerPrefixRegex = /^Bearer\s+/i; const scopeSplitRegex = /\s+/; const OAUTH_CONSENT_PATH = '/api/auth/oauth2/consent'; const OAUTH_AUTHORIZE_PATH = '/api/auth/oauth2/authorize'; +const WELL_KNOWN_AUTH_SERVER_PATH = '/.well-known/oauth-authorization-server'; +const WELL_KNOWN_OPENID_CONFIG_PATH = '/.well-known/openid-configuration'; +const WELL_KNOWN_AUTH_BASE_PATH = '/api/auth'; const WELL_KNOWN_ALLOWED_ORIGINS = new Set(['https://claude.ai', 'https://claude.com']); /** localhost origins get CORS on the well-known endpoints so MCP Inspector can drive OAuth discovery. */ const LOCALHOST_WELL_KNOWN_ORIGIN = /^http:\/\/localhost:\d+$/; @@ -126,12 +129,7 @@ async function handleLocalE2EAuth(request: Request, env: Env): Promise>; @@ -95,6 +100,7 @@ export const adminAuthPlugin = new Elysia({ name: 'packrat-admin-auth' }).macro( const localUser = await getLocalE2EUserFromRequest(env, request); if (localUser) return status(403, { error: 'Forbidden' }); + const getAuth = await loadAuth(); const auth = await getAuth(env); let session: Awaited>; diff --git a/packages/api/src/routes/__tests__/chat-e2e-stub.test.ts b/packages/api/src/routes/__tests__/chat-e2e-stub.test.ts new file mode 100644 index 0000000000..1e372a505b --- /dev/null +++ b/packages/api/src/routes/__tests__/chat-e2e-stub.test.ts @@ -0,0 +1,99 @@ +import { getAuth } from '@packrat/api/auth'; +import { chatRoutes } from '@packrat/api/routes/chat'; +import { createAIProvider } from '@packrat/api/utils/ai/provider'; +import { getEnv } from '@packrat/api/utils/env-validation'; +import { Elysia } from 'elysia'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@packrat/api/auth', () => ({ + getAuth: vi.fn(), +})); + +vi.mock('@packrat/api/auth/local-e2e', () => ({ + getLocalE2EUserFromRequest: vi.fn(async () => null), +})); + +vi.mock('@packrat/api/auth/mcp-token', () => ({ + resolveMcpBearerUser: vi.fn(async () => null), +})); + +vi.mock('@packrat/api/db', () => ({ + createDb: vi.fn(), +})); + +vi.mock('@packrat/api/utils/ai/provider', () => ({ + createAIProvider: vi.fn(), +})); + +vi.mock('@packrat/api/utils/ai/tools', () => ({ + createTools: vi.fn(() => ({})), +})); + +vi.mock('@packrat/api/utils/DbUtils', () => ({ + getSchemaInfo: vi.fn(async () => 'e2e schema'), +})); + +vi.mock('@packrat/api/utils/env-validation', () => ({ + getEnv: vi.fn(), +})); + +vi.mock('@packrat/api/utils/queryMetrics', () => ({ + setQueryMetricsUser: vi.fn(), +})); + +vi.mock('@packrat/api/utils/sentry', () => ({ + apiAddBreadcrumb: vi.fn(), + captureApiException: vi.fn(), + setApiUser: vi.fn(), +})); + +const mockGetAuth = getAuth as unknown as ReturnType; +const mockGetEnv = getEnv as unknown as ReturnType; +const mockCreateAIProvider = createAIProvider as unknown as ReturnType; + +const app = new Elysia().use(chatRoutes); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetEnv.mockReturnValue({ + AI_PROVIDER: 'openai', + OPENAI_API_KEY: 'sk-test', + } as never); + mockGetAuth.mockResolvedValue({ + api: { + getSession: vi.fn(async () => ({ + user: { + id: 'test-user-id', + email: 'swift-e2e@example.com', + name: 'Swift E2E', + role: 'USER', + }, + })), + }, + } as never); +}); + +describe('chat E2E stub route', () => { + it('serves deterministic chat stream content for local stub keys', async () => { + const response = await app.handle( + new Request('http://localhost/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + date: '2026-07-17', + messages: [ + { + id: 'test-message', + role: 'user', + parts: [{ type: 'text', text: 'Hi' }], + }, + ], + }), + }), + ); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toContain('three essential items'); + expect(mockCreateAIProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/routes/__tests__/weather-e2e-stub.test.ts b/packages/api/src/routes/__tests__/weather-e2e-stub.test.ts new file mode 100644 index 0000000000..103c413846 --- /dev/null +++ b/packages/api/src/routes/__tests__/weather-e2e-stub.test.ts @@ -0,0 +1,102 @@ +import { getAuth } from '@packrat/api/auth'; +import { weatherRoutes } from '@packrat/api/routes/weather'; +import { getEnv } from '@packrat/api/utils/env-validation'; +import { captureApiException } from '@packrat/api/utils/sentry'; +import { Elysia } from 'elysia'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@packrat/api/auth', () => ({ + getAuth: vi.fn(), +})); + +vi.mock('@packrat/api/auth/local-e2e', () => ({ + getLocalE2EUserFromRequest: vi.fn(async () => null), +})); + +vi.mock('@packrat/api/auth/mcp-token', () => ({ + resolveMcpBearerUser: vi.fn(async () => null), +})); + +vi.mock('@packrat/api/utils/env-validation', () => ({ + getEnv: vi.fn(), +})); + +vi.mock('@packrat/api/utils/queryMetrics', () => ({ + setQueryMetricsUser: vi.fn(), +})); + +vi.mock('@packrat/api/utils/sentry', () => ({ + apiAddBreadcrumb: vi.fn(), + captureApiException: vi.fn(), + setApiUser: vi.fn(), +})); + +const mockGetAuth = vi.mocked(getAuth); +const mockGetEnv = vi.mocked(getEnv); +const mockCaptureApiException = vi.mocked(captureApiException); +const mockFetch = vi.fn(); + +const app = new Elysia().use(weatherRoutes); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetEnv.mockReturnValue({ + WEATHER_API_KEY: 'weather-e2e-stub-local', + } as never); + mockGetAuth.mockResolvedValue({ + api: { + getSession: vi.fn(async () => ({ + user: { + id: 'test-user-id', + email: 'swift-e2e@example.com', + name: 'Swift E2E', + role: 'USER', + }, + })), + }, + } as never); + vi.stubGlobal('fetch', mockFetch); +}); + +describe('weather E2E stub routes', () => { + it('serves deterministic search and forecast data without calling WeatherAPI', async () => { + const search = await app.handle(new Request('http://localhost/weather/search?q=Denver')); + expect(search.status).toBe(200); + const locations = (await search.json()) as Array<{ id: number; name: string }>; + + expect(locations[0]).toMatchObject({ id: 5419384, name: 'Denver' }); + + const forecast = await app.handle( + new Request(`http://localhost/weather/forecast?id=${locations[0]?.id ?? 0}`), + ); + expect(forecast.status).toBe(200); + const body = (await forecast.json()) as { + location: { id: number; name: string }; + current: { temp_f: number }; + forecast: { forecastday: unknown[] }; + }; + + expect(body.location).toMatchObject({ id: 5419384, name: 'Denver' }); + expect(body.current.temp_f).toBe(72); + expect(body.forecast.forecastday).toHaveLength(10); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockCaptureApiException).not.toHaveBeenCalled(); + }); + + it('serves by-name forecast data and not-found responses deterministically', async () => { + const yosemite = await app.handle(new Request('http://localhost/weather/by-name?q=Yosemite')); + expect(yosemite.status).toBe(200); + await expect(yosemite.json()).resolves.toMatchObject({ + location: { name: 'Yosemite Valley', region: 'California' }, + current: { temp_f: 68 }, + }); + + const missing = await app.handle(new Request('http://localhost/weather/by-name?q=Atlantis')); + expect(missing.status).toBe(404); + await expect(missing.json()).resolves.toMatchObject({ + error: 'No weather location matched "Atlantis"', + }); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockCaptureApiException).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/routes/admin/analytics/catalog.ts b/packages/api/src/routes/admin/analytics/catalog.ts index ec5d241618..1d149f3fea 100644 --- a/packages/api/src/routes/admin/analytics/catalog.ts +++ b/packages/api/src/routes/admin/analytics/catalog.ts @@ -1,10 +1,11 @@ import { createDb } from '@packrat/api/db'; +import { queryRows } from '@packrat/api/db/queryRows'; import { R2BucketService } from '@packrat/api/services/r2-bucket'; import { getEnv } from '@packrat/api/utils/env-validation'; import { captureApiException } from '@packrat/api/utils/sentry'; import type { CatalogEtlWorkflowParams } from '@packrat/api/workflows/catalog-etl-workflow'; import { type ChunkSpec, chunkCsvForR2 } from '@packrat/api/workflows/shared/chunkCsvForR2'; -import { catalogItems, etlJobs, invalidItemLogs } from '@packrat/db'; +import { catalogItems, etlJobs, invalidItemLogs } from '@packrat/db/schema'; import { AdminErrorResponses, BrandRowSchema, @@ -491,7 +492,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) ]); return { - topErrors: rows.rows.map((r) => ({ + topErrors: queryRows(rows).map((r) => ({ field: r.field, reason: r.reason, count: r.count, @@ -551,7 +552,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) return { jobId: params.jobId, - errorBreakdown: breakdown.rows.map((r) => ({ + errorBreakdown: queryRows(breakdown).map((r) => ({ field: r.field, reason: r.reason, count: r.count, @@ -861,7 +862,7 @@ export const catalogAnalyticsRoutes = new Elysia({ prefix: '/catalog' }) GROUP BY lpi.source, lj.last_id, lj.last_at ORDER BY lpi.source `); - const rows = (auditResult.rows ?? auditResult) as Array<{ + const rows = queryRows(auditResult) as Array<{ source: string; total_items: number; last_id: string | null; diff --git a/packages/api/src/routes/admin/analytics/platform.ts b/packages/api/src/routes/admin/analytics/platform.ts index 142d8522a9..bdfcd0bfbd 100644 --- a/packages/api/src/routes/admin/analytics/platform.ts +++ b/packages/api/src/routes/admin/analytics/platform.ts @@ -1,5 +1,12 @@ import { createDb } from '@packrat/api/db'; -import { catalogItems, packs, posts, trailConditionReports, trips, users } from '@packrat/db'; +import { + catalogItems, + packs, + posts, + trailConditionReports, + trips, + users, +} from '@packrat/db/schema'; import { ActiveUsersSchema, ActivityPointSchema, diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index 67308c6194..533013d692 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -1,12 +1,11 @@ import { cors } from '@elysiajs/cors'; -import { getAuth } from '@packrat/api/auth'; import { resolveMcpBearerUser } from '@packrat/api/auth/mcp-token'; import { createDb } from '@packrat/api/db'; import { verifyCFAccessRequest } from '@packrat/api/middleware/cfAccess'; import { timingSafeEqual } from '@packrat/api/utils/auth'; import { getEnv } from '@packrat/api/utils/env-validation'; import { captureApiException } from '@packrat/api/utils/sentry'; -import { catalogItems, packs, users } from '@packrat/db'; +import { catalogItems, packs, users } from '@packrat/db/schema'; import { assertAllDefined, queryBoolean } from '@packrat/guards'; import { AdminCatalogListSchema, @@ -39,6 +38,11 @@ const ADMIN_TOKEN_TTL_SECONDS = 3600; // 1 hour const ADMIN_JWT_ISSUER = 'packrat-api'; const ADMIN_JWT_AUDIENCE = 'packrat-admin'; +async function loadAuth() { + const { getAuth } = await import('@packrat/api/auth'); + return getAuth; +} + function checkAdminCredentials({ username, password, @@ -115,6 +119,7 @@ async function verifyBetterAuthAdmin(request: Request): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), BETTER_AUTH_GUARD_TIMEOUT_MS); try { + const getAuth = await loadAuth(); const auth = await getAuth(env); // Run the session lookup in a Promise.race against the timeout so a // slow/hanging Neon-backed Better Auth doesn't block the guard. diff --git a/packages/api/src/routes/admin/trails.ts b/packages/api/src/routes/admin/trails.ts index 0a6bd1fa3d..084fbe57af 100644 --- a/packages/api/src/routes/admin/trails.ts +++ b/packages/api/src/routes/admin/trails.ts @@ -1,5 +1,6 @@ import { createDb, createOsmDb } from '@packrat/api/db'; -import { trailConditionReports, users } from '@packrat/db'; +import { firstQueryRow, queryRows } from '@packrat/api/db/queryRows'; +import { trailConditionReports, users } from '@packrat/db/schema'; import { queryBoolean } from '@packrat/guards'; import { AdminErrorResponses, @@ -57,7 +58,7 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' }) LIMIT ${limit + 1} OFFSET ${offset} `); - const rows = z.array(RouteSearchRowSchema).parse(result.rows); + const rows = z.array(RouteSearchRowSchema).parse(queryRows(result)); const hasMore = rows.length > limit; const page = rows.slice(0, limit); @@ -140,7 +141,7 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' }) geojson: z.string().nullable(), }); - const row = DetailRowSchema.nullable().parse(result.rows?.[0] ?? null); + const row = DetailRowSchema.nullable().parse(firstQueryRow(result) ?? null); if (!row) return status(404, { error: 'Trail not found' }); let geometry: unknown = null; @@ -206,7 +207,7 @@ export const adminTrailsRoutes = new Elysia({ prefix: '/trails' }) WHERE osm_id = ${osmId} `); - const row = RouteSearchRowSchema.nullable().parse(result.rows?.[0] ?? null); + const row = RouteSearchRowSchema.nullable().parse(firstQueryRow(result) ?? null); if (!row) return status(404, { error: 'Trail not found' }); return { diff --git a/packages/api/src/routes/catalog/index.ts b/packages/api/src/routes/catalog/index.ts index 86fcecc62d..3e4a150334 100644 --- a/packages/api/src/routes/catalog/index.ts +++ b/packages/api/src/routes/catalog/index.ts @@ -6,10 +6,11 @@ import { queueCatalogETL } from '@packrat/api/services/etl/queue'; import { R2BucketService } from '@packrat/api/services/r2-bucket'; import { buildInstanceId } from '@packrat/api/utils/buildInstanceId'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; -import { getEnv } from '@packrat/api/utils/env-validation'; +import { getEnv, isLocalE2EApiEnv } from '@packrat/api/utils/env-validation'; +import { captureApiException } from '@packrat/api/utils/sentry'; import type { CatalogEtlWorkflowParams } from '@packrat/api/workflows/catalog-etl-workflow'; import { type ChunkSpec, chunkCsvForR2 } from '@packrat/api/workflows/shared/chunkCsvForR2'; -import { catalogItems, etlJobs, packItems } from '@packrat/db'; +import { catalogItems, etlJobs, packItems } from '@packrat/db/schema'; import { isNumber, isObject, isString } from '@packrat/guards'; import { CatalogCategoriesResponseSchema, @@ -38,6 +39,75 @@ import { import { Elysia, NotFoundError, status } from 'elysia'; import { z } from 'zod'; +const isLocalE2ECatalogEnv = () => { + const { NEON_DATABASE_URL, OPENAI_API_KEY } = getEnv(); + return isLocalE2EApiEnv({ + databaseUrl: NEON_DATABASE_URL, + openAiApiKey: OPENAI_API_KEY, + requireStubOpenAI: true, + }); +}; + +const localE2ECatalogItems = [ + { + id: 7001, + name: 'Copper Spur HV UL2 Tent', + productUrl: 'https://example.test/catalog/copper-spur', + sku: 'E2E-COPPER-SPUR', + weight: 1420, + weightUnit: 'g', + description: + 'Freestanding two-person backpacking tent used for deterministic local E2E search.', + categories: ['shelter', 'backpacking'], + images: [] as string[], + brand: 'Big Agnes', + model: 'Copper Spur HV UL2', + ratingValue: 4.8, + color: 'Orange', + size: '2 person', + price: 549.95, + availability: 'in_stock', + seller: 'PackRat E2E', + productSku: 'E2E-COPPER-SPUR', + material: 'Nylon', + currency: 'USD', + condition: 'new', + reviewCount: 42, + usageCount: 8, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + similarity: 0.92, + }, + { + id: 7002, + name: 'Hyperlite 40L Pack', + productUrl: 'https://example.test/catalog/hyperlite-40', + sku: 'E2E-HYPERLITE-40', + weight: 910, + weightUnit: 'g', + description: 'Lightweight framed pack fixture for local E2E similar gear results.', + categories: ['pack', 'backpacking'], + images: [] as string[], + brand: 'Hyperlite', + model: '40L', + ratingValue: 4.6, + color: 'White', + size: '40 L', + price: 379, + availability: 'in_stock', + seller: 'PackRat E2E', + productSku: 'E2E-HYPERLITE-40', + material: 'Dyneema composite', + currency: 'USD', + condition: 'new', + reviewCount: 27, + usageCount: 5, + createdAt: '2026-01-02T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + similarity: 0.84, + }, +]; + export const catalogRoutes = new Elysia({ prefix: '/catalog' }) .model({ 'catalog.CatalogCategoriesResponse': CatalogCategoriesResponseSchema, @@ -105,9 +175,36 @@ export const catalogRoutes = new Elysia({ prefix: '/catalog' }) async ({ query }) => { try { const { q: searchQuery, limit = 10, offset = 0 } = query; + if (isLocalE2ECatalogEnv()) { + const normalizedQuery = searchQuery.trim().toLowerCase(); + const matched = localE2ECatalogItems.filter((item) => { + const haystack = [ + item.name, + item.brand, + item.model, + item.description, + ...(item.categories ?? []), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(normalizedQuery) || normalizedQuery.length === 0; + }); + const items = matched.slice(offset, offset + limit); + + return { + items, + total: matched.length, + limit, + offset, + nextOffset: offset + items.length, + }; + } + const catalogService = new CatalogService(); return await catalogService.vectorSearch({ q: searchQuery, opts: { limit, offset } }); } catch (error) { + captureApiException({ error, operation: 'catalog.vectorSearch' }); console.error('Vector search error:', error); return status(500, { error: 'Failed to search catalog items' }); } diff --git a/packages/api/src/routes/chat.ts b/packages/api/src/routes/chat.ts index 0353afc1ac..c482c69a1e 100644 --- a/packages/api/src/routes/chat.ts +++ b/packages/api/src/routes/chat.ts @@ -4,20 +4,14 @@ import { createAIProvider } from '@packrat/api/utils/ai/provider'; import { createTools } from '@packrat/api/utils/ai/tools'; import { getEnv } from '@packrat/api/utils/env-validation'; import { apiAddBreadcrumb, captureApiException } from '@packrat/api/utils/sentry'; -import { reportedContent } from '@packrat/db'; +import { reportedContent } from '@packrat/db/schema'; import { ChatRequestSchema, CreateReportRequestSchema, UpdateReportStatusRequestSchema, } from '@packrat/schemas/chat'; -import { - convertToModelMessages, - createUIMessageStream, - createUIMessageStreamResponse, - stepCountIs, - streamText, - type UIMessage, -} from 'ai'; +import { safeJsonStringify } from '@packrat/utils'; +import type { UIMessage } from 'ai'; import { eq } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { z } from 'zod'; @@ -25,7 +19,40 @@ import { DEFAULT_MODELS } from '../utils/ai/models'; import { getSchemaInfo } from '../utils/DbUtils'; const isE2EStubOpenAiKey = (openAiApiKey: string | undefined) => - openAiApiKey?.startsWith('sk-e2e-stub-') === true; + openAiApiKey === 'sk-test' || openAiApiKey?.startsWith('sk-e2e-stub-') === true; + +function e2eChatStreamResponse() { + const text = + 'For this e2e pack, three essential items are a shelter, a sleep system, and water treatment.'; + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + controller.enqueue( + encoder.encode( + `data: ${safeJsonStringify({ type: 'text-start', id: 'e2e-chat-response' })}\n\n`, + ), + ); + controller.enqueue( + encoder.encode( + `data: ${safeJsonStringify({ type: 'text-delta', id: 'e2e-chat-response', delta: text })}\n\n`, + ), + ); + controller.enqueue( + encoder.encode( + `data: ${safeJsonStringify({ type: 'text-end', id: 'e2e-chat-response' })}\n\n`, + ), + ); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache', + }, + }); +} export const chatRoutes = new Elysia({ prefix: '/chat' }) .model({ @@ -63,7 +90,6 @@ export const chatRoutes = new Elysia({ prefix: '/chat' }) speedUnit, } = typedBody; - const tools = createTools(user.userId); const schemaInfo = await getSchemaInfo(); let systemPrompt = ` @@ -118,25 +144,10 @@ export const chatRoutes = new Elysia({ prefix: '/chat' }) } if (isE2EStubOpenAiKey(OPENAI_API_KEY)) { - return createUIMessageStreamResponse({ - stream: createUIMessageStream({ - originalMessages: messages, - execute: ({ writer }) => { - const id = 'e2e-chat-response'; - writer.write({ type: 'text-start', id }); - writer.write({ - type: 'text-delta', - id, - delta: - 'For this e2e pack, three essential items are a shelter, a sleep system, and water treatment.', - }); - writer.write({ type: 'text-end', id }); - writer.write({ type: 'finish', finishReason: 'stop' }); - }, - }), - }); + return e2eChatStreamResponse(); } + const tools = await createTools(user.userId); const aiProvider = createAIProvider({ openAiApiKey: OPENAI_API_KEY, provider: AI_PROVIDER, @@ -170,6 +181,7 @@ export const chatRoutes = new Elysia({ prefix: '/chat' }) }, }); + const { convertToModelMessages, stepCountIs, streamText } = await import('ai'); const result = streamText({ model: aiProvider(DEFAULT_MODELS.OPENAI_CHAT), system: systemPrompt, diff --git a/packages/api/src/routes/feed/index.ts b/packages/api/src/routes/feed/index.ts index 0b184e2a12..e05f2762e8 100644 --- a/packages/api/src/routes/feed/index.ts +++ b/packages/api/src/routes/feed/index.ts @@ -1,6 +1,6 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; -import { commentLikes, postComments, postLikes, posts, users } from '@packrat/db'; +import { commentLikes, postComments, postLikes, posts, users } from '@packrat/db/schema'; import { CreateCommentRequestSchema, CreatePostRequestSchema, diff --git a/packages/api/src/routes/guides/index.ts b/packages/api/src/routes/guides/index.ts index 7254801324..98ddbd3e93 100644 --- a/packages/api/src/routes/guides/index.ts +++ b/packages/api/src/routes/guides/index.ts @@ -1,6 +1,7 @@ import { authPlugin } from '@packrat/api/middleware/auth'; -import { R2BucketService } from '@packrat/api/services/r2-bucket'; -import { getEnv } from '@packrat/api/utils/env-validation'; +import type { R2BucketService } from '@packrat/api/services/r2-bucket'; +import { getEnv, isLocalE2EApiEnv } from '@packrat/api/utils/env-validation'; +import { captureApiException } from '@packrat/api/utils/sentry'; import { asNumber, asString, isArray } from '@packrat/guards'; import { GuideCategoriesResponseSchema, @@ -10,13 +11,78 @@ import { GuidesQuerySchema, GuidesResponseSchema, } from '@packrat/schemas/guides'; +import { Elysia, NotFoundError, status } from 'elysia'; +import { z } from 'zod'; const MDX_EXT_RE = /\.(mdx?|md)$/; const DASH_RE = /-/g; - -import { Elysia, NotFoundError, status } from 'elysia'; -import matter from 'gray-matter'; -import { z } from 'zod'; +async function parseGuideMatter(content: string) { + const { default: matter } = await import('gray-matter'); + return matter(content); +} + +async function createGuidesBucket(): Promise { + const { R2BucketService } = await import('@packrat/api/services/r2-bucket'); + return new R2BucketService({ + env: getEnv(), + bucketType: 'guides', + }); +} + +const isLocalE2EGuidesEnv = () => { + const { E2E_TEST_USER_ID, NEON_DATABASE_URL } = getEnv(); + return isLocalE2EApiEnv({ + databaseUrl: NEON_DATABASE_URL, + e2eUserId: E2E_TEST_USER_ID, + requireE2EUser: true, + }); +}; + +const localE2EGuides = [ + { + id: 'e2e-layering-for-shoulder-season', + key: 'e2e-layering-for-shoulder-season.mdx', + title: 'Layering for Shoulder Season', + category: 'skills', + categories: ['skills', 'clothing'], + description: 'How to tune warmth, rain protection, and ventilation for variable trail days.', + author: 'PackRat', + readingTime: 6, + difficulty: 'beginner', + content: + 'Shoulder season trips work best with a breathable base layer, active insulation, rain protection, and a dry sleep layer. Keep the rain shell and headlamp easy to reach.', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'e2e-lightweight-weekend-pack', + key: 'e2e-lightweight-weekend-pack.mdx', + title: 'Lightweight Weekend Pack', + category: 'packing', + categories: ['packing', 'backpacking'], + description: 'A compact framework for balancing comfort, safety, and low carried weight.', + author: 'PackRat', + readingTime: 5, + difficulty: 'intermediate', + content: + 'Start with shelter, sleep, water, food, layers, navigation, and repair. Cut duplicate comfort items only after essentials are covered.', + createdAt: '2026-01-02T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, +]; + +function paginateGuides(input: { items: T[]; page: number; limit: number }) { + const { items, page, limit } = input; + const totalCount = items.length; + const offset = (page - 1) * limit; + return { + items: items.slice(offset, offset + limit), + totalCount, + page, + limit, + totalPages: Math.ceil(totalCount / limit), + }; +} export const guidesRoutes = new Elysia({ prefix: '/guides' }) .model({ @@ -54,10 +120,25 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) : undefined; try { - const bucket = new R2BucketService({ - env: getEnv(), - bucketType: 'guides', - }); + if (isLocalE2EGuidesEnv()) { + let guides = localE2EGuides.map(({ content: _content, ...guide }) => guide); + if (category) { + guides = guides.filter( + (guide) => guide.category === category || guide.categories.includes(category), + ); + } + guides.sort((a, b) => { + if (!sort) return a.title.localeCompare(b.title); + const aValue = String(a[sort.field as keyof typeof a]); + const bValue = String(b[sort.field as keyof typeof b]); + return sort.order === 'asc' + ? aValue.localeCompare(bValue) + : bValue.localeCompare(aValue); + }); + return GuidesResponseSchema.parse(paginateGuides({ items: guides, page, limit })); + } + + const bucket = await createGuidesBucket(); const list = await bucket.list(); @@ -68,7 +149,7 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) const response = await bucket.get(obj.key); if (response) { const text = await response.text(); - const { data } = matter(text); + const { data } = await parseGuideMatter(text); frontmatter = data; } } catch (error) { @@ -131,6 +212,7 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) totalPages, }); } catch (error) { + captureApiException({ error, operation: 'guides.list' }); console.error('Error listing guides:', error); throw error; } @@ -152,10 +234,14 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) '/categories', async () => { try { - const bucket = new R2BucketService({ - env: getEnv(), - bucketType: 'guides', - }); + if (isLocalE2EGuidesEnv()) { + const categories = [ + ...new Set(localE2EGuides.flatMap((guide) => guide.categories)), + ].sort(); + return GuideCategoriesResponseSchema.parse({ categories, count: categories.length }); + } + + const bucket = await createGuidesBucket(); const list = await bucket.list(); const categoriesSet = new Set(); @@ -165,7 +251,7 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) const response = await bucket.get(obj.key); if (response) { const text = await response.text(); - const { data } = matter(text); + const { data } = await parseGuideMatter(text); if (data.categories && Array.isArray(data.categories)) { return data.categories as string[]; } @@ -186,6 +272,7 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) const categories = Array.from(categoriesSet).sort(); return GuideCategoriesResponseSchema.parse({ categories, count: categories.length }); } catch (error) { + captureApiException({ error, operation: 'guides.categories' }); console.error('Error getting guide categories:', error); throw error; } @@ -212,10 +299,31 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) const searchQuery = q.toLowerCase(); try { - const bucket = new R2BucketService({ - env: getEnv(), - bucketType: 'guides', - }); + if (isLocalE2EGuidesEnv()) { + const guides = localE2EGuides + .filter( + (guide) => + !category || guide.category === category || guide.categories.includes(category), + ) + .map((guide) => { + let score = 0; + if (guide.title.toLowerCase().includes(searchQuery)) score += 10; + if (guide.description.toLowerCase().includes(searchQuery)) score += 5; + if (guide.content.toLowerCase().includes(searchQuery)) score += 1; + const { content: _content, ...summary } = guide; + return score > 0 ? { ...summary, score } : null; + }) + .filter((guide): guide is NonNullable => guide !== null) + .sort((a, b) => b.score - a.score) + .map(({ score: _score, ...guide }) => guide); + + return GuideSearchResponseSchema.parse({ + ...paginateGuides({ items: guides, page, limit }), + query: q, + }); + } + + const bucket = await createGuidesBucket(); const list = await bucket.list({ limit: 1000 }); const searchResults = await Promise.all( @@ -285,6 +393,7 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) query: q, }); } catch (error) { + captureApiException({ error, operation: 'guides.search' }); console.error('Error searching guides:', error); throw error; } @@ -307,10 +416,13 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) const { id } = params; try { - const bucket = new R2BucketService({ - env: getEnv(), - bucketType: 'guides', - }); + if (isLocalE2EGuidesEnv()) { + const guide = localE2EGuides.find((candidate) => candidate.id === id); + if (!guide) throw new NotFoundError('Guide not found'); + return GuideDetailSchema.parse(guide); + } + + const bucket = await createGuidesBucket(); let key = `${id}.mdx`; let object = await bucket.get(key); @@ -327,7 +439,7 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) const headResult = await bucket.head(key); const metadata = headResult?.customMetadata || {}; const rawContent = await object.text(); - const { data: frontmatter, content } = matter(rawContent); + const { data: frontmatter, content } = await parseGuideMatter(rawContent); return GuideDetailSchema.parse({ id, @@ -344,6 +456,7 @@ export const guidesRoutes = new Elysia({ prefix: '/guides' }) updatedAt: object.uploaded.toISOString(), }); } catch (error) { + captureApiException({ error, operation: 'guides.detail' }); console.error('Error fetching guide:', error); throw error; } diff --git a/packages/api/src/routes/knowledgeBase/reader.ts b/packages/api/src/routes/knowledgeBase/reader.ts index 00f8ab5b55..2976e522f0 100644 --- a/packages/api/src/routes/knowledgeBase/reader.ts +++ b/packages/api/src/routes/knowledgeBase/reader.ts @@ -1,6 +1,4 @@ -import { Readability } from '@mozilla/readability'; import { Elysia, status } from 'elysia'; -import { parseHTML } from 'linkedom'; import { z } from 'zod'; // \u2500\u2500 HTML \u2192 Markdown conversion patterns \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 @@ -92,6 +90,10 @@ export const readerRoutes = new Elysia({ prefix: '/reader' }).post( const html = await response.text(); + const [{ Readability }, { parseHTML }] = await Promise.all([ + import('@mozilla/readability'), + import('linkedom'), + ]); const { window } = parseHTML(html); const reader = new Readability(window.document); const article = reader.parse(); diff --git a/packages/api/src/routes/packTemplates/index.ts b/packages/api/src/routes/packTemplates/index.ts index 44b7639956..5517a78f01 100644 --- a/packages/api/src/routes/packTemplates/index.ts +++ b/packages/api/src/routes/packTemplates/index.ts @@ -4,7 +4,7 @@ import { CatalogService } from '@packrat/api/services/catalogService'; import { createGoogleAIProvider } from '@packrat/api/utils/ai/provider'; import { getEnv } from '@packrat/api/utils/env-validation'; import { setQueryTag } from '@packrat/api/utils/queryMetrics'; -import { type PackTemplate, packTemplateItems, packTemplates } from '@packrat/db'; +import { type PackTemplate, packTemplateItems, packTemplates } from '@packrat/db/schema'; import { assertDefined } from '@packrat/guards'; import { AIPackAnalysisSchema, @@ -15,7 +15,6 @@ import { UpdatePackTemplateRequestSchema, } from '@packrat/schemas/packTemplates'; import { safeJsonStringify } from '@packrat/utils'; -import { generateObject } from 'ai'; import { and, eq, or, sql } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { fetchTranscript } from 'youtube-transcript'; @@ -314,6 +313,7 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' }) throw new Error('No content found in TikTok post (no images or video)'); } + const { generateObject } = await import('ai'); const { object: analysis } = await generateObject({ model: google('gemini-3-flash-preview'), schema: AIPackAnalysisSchema, diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index 72ba348c11..2ca2d68c5a 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -1,4 +1,3 @@ -import { GetObjectCommand } from '@aws-sdk/client-s3'; import { createDb } from '@packrat/api/db'; import { adminAuthPlugin, authPlugin } from '@packrat/api/middleware/auth'; import { ImageDetectionService, PackService } from '@packrat/api/services'; @@ -21,7 +20,7 @@ import { packItems, packs, packWeightHistory, -} from '@packrat/db'; +} from '@packrat/db/schema'; import { AnalyzeImageRequestSchema } from '@packrat/schemas/imageDetection'; import { AddPackItemBodySchema, @@ -258,6 +257,7 @@ export const packsRoutes = new Elysia({ prefix: '/packs' }) } const { PACKRAT_BUCKET_R2_BUCKET_NAME, PACKRAT_BUCKET } = getEnv(); + const { GetObjectCommand } = await import('@aws-sdk/client-s3'); const command = new GetObjectCommand({ Bucket: PACKRAT_BUCKET_R2_BUCKET_NAME, Key: image, diff --git a/packages/api/src/routes/seasonSuggestions.ts b/packages/api/src/routes/seasonSuggestions.ts index 8ba1f2b249..ec9216a2cd 100644 --- a/packages/api/src/routes/seasonSuggestions.ts +++ b/packages/api/src/routes/seasonSuggestions.ts @@ -1,15 +1,24 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; import { createAIProvider } from '@packrat/api/utils/ai/provider'; -import { getEnv } from '@packrat/api/utils/env-validation'; -import { type PackItem, packItems } from '@packrat/db'; +import { getEnv, isLocalE2EApiEnv } from '@packrat/api/utils/env-validation'; +import { type PackItem, packItems } from '@packrat/db/schema'; import { SeasonSuggestionsRequestSchema } from '@packrat/schemas/seasonSuggestions'; -import { generateObject } from 'ai'; import { and, eq } from 'drizzle-orm'; import { Elysia, status } from 'elysia'; import { z } from 'zod'; import { DEFAULT_MODELS } from '../utils/ai/models'; +const isLocalE2ESeasonSuggestionEnv = (input: { + openAiApiKey: string | undefined; + databaseUrl: string; +}) => + isLocalE2EApiEnv({ + databaseUrl: input.databaseUrl, + openAiApiKey: input.openAiApiKey, + requireStubOpenAI: true, + }); + /** * Formats user inventory items for AI processing */ @@ -31,8 +40,84 @@ export const seasonSuggestionsRoutes = new Elysia({ prefix: '/season-suggestions '/', async ({ body, user }) => { const { location, date } = body; - const db = createDb(); + const { + OPENAI_API_KEY, + AI_PROVIDER, + CLOUDFLARE_ACCOUNT_ID, + CLOUDFLARE_AI_GATEWAY_ID, + CLOUDFLARE_API_TOKEN, + AI, + NEON_DATABASE_URL, + } = getEnv(); + + if ( + isLocalE2ESeasonSuggestionEnv({ + openAiApiKey: OPENAI_API_KEY, + databaseUrl: NEON_DATABASE_URL, + }) + ) { + const suggestionItems = [ + { + id: 'e2e-season-rain-shell', + name: 'Rain shell', + description: 'Waterproof layer for shoulder-season weather.', + weight: 210, + weightUnit: 'g', + quantity: 1, + category: 'clothing', + consumable: false, + worn: false, + image: null, + notes: 'Keep this accessible for changing weather.', + catalogItemId: null, + }, + { + id: 'e2e-season-headlamp', + name: 'Headlamp', + description: 'Reliable lighting for short autumn daylight.', + weight: 85, + weightUnit: 'g', + quantity: 1, + category: 'lighting', + consumable: false, + worn: false, + image: null, + notes: 'Pack fresh batteries before leaving.', + catalogItemId: null, + }, + { + id: 'e2e-season-warm-layer', + name: 'Warm layer', + description: 'Insulating layer for cool evenings and exposed breaks.', + weight: 320, + weightUnit: 'g', + quantity: 1, + category: 'clothing', + consumable: false, + worn: true, + image: null, + notes: 'Wear or keep near the top of the pack.', + catalogItemId: null, + }, + ]; + return { + suggestions: [ + { + name: 'Shoulder Season Overnight', + description: `Balanced kit for ${location} with warmth, rain protection, and reliable camp basics.`, + category: 'backpacking', + tags: ['e2e', 'shoulder season', 'overnight'], + items: suggestionItems, + }, + ], + totalInventoryItems: suggestionItems.length, + location, + season: 'fall', + }; + } + + const db = createDb(); const items = await db.tag('seasonSuggestions.getUserInventory').query.packItems.findMany({ where: and(eq(packItems.userId, user.userId), eq(packItems.deleted, false)), columns: { embedding: false }, @@ -56,14 +141,6 @@ Date: ${date} Available Inventory Items: ${inventoryFormatted}`; - const { - OPENAI_API_KEY, - AI_PROVIDER, - CLOUDFLARE_ACCOUNT_ID, - CLOUDFLARE_AI_GATEWAY_ID, - CLOUDFLARE_API_TOKEN, - AI, - } = getEnv(); const aiProvider = createAIProvider({ openAiApiKey: OPENAI_API_KEY, provider: AI_PROVIDER, @@ -72,6 +149,7 @@ ${inventoryFormatted}`; cloudflareApiToken: CLOUDFLARE_API_TOKEN, cloudflareAiBinding: AI, }); + const { generateObject } = await import('ai'); const { object } = await generateObject({ model: aiProvider(DEFAULT_MODELS.OPENAI_CHAT), schema: z.object({ diff --git a/packages/api/src/routes/trailConditions/reports.ts b/packages/api/src/routes/trailConditions/reports.ts index 9873433dc0..b9b4a00eb0 100644 --- a/packages/api/src/routes/trailConditions/reports.ts +++ b/packages/api/src/routes/trailConditions/reports.ts @@ -1,8 +1,8 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; import { captureApiException } from '@packrat/api/utils/sentry'; -import type { NewTrailConditionReport } from '@packrat/db'; -import { trailConditionReports } from '@packrat/db'; +import type { NewTrailConditionReport } from '@packrat/db/schema'; +import { trailConditionReports } from '@packrat/db/schema'; import { CreateTrailConditionReportRequestSchema, UpdateTrailConditionReportRequestSchema, diff --git a/packages/api/src/routes/trails/index.ts b/packages/api/src/routes/trails/index.ts index 0910fe7dfd..97e0c3e318 100644 --- a/packages/api/src/routes/trails/index.ts +++ b/packages/api/src/routes/trails/index.ts @@ -1,4 +1,5 @@ import { createOsmDb } from '@packrat/api/db'; +import { firstQueryRow, queryRows } from '@packrat/api/db/queryRows'; import { authPlugin } from '@packrat/api/middleware/auth'; import { stitchRouteGeometry } from '@packrat/api/services/trails'; import { captureApiException } from '@packrat/api/utils/sentry'; @@ -74,7 +75,7 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) LIMIT ${limit + 1} OFFSET ${offset} `); - const rows = z.array(RouteSearchRowSchema).parse(result.rows); + const rows = z.array(RouteSearchRowSchema).parse(queryRows(result)); const hasMore = rows.length > limit; const page = rows.slice(0, limit); @@ -157,7 +158,7 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) WHERE osm_id = ${osmId} `); - const row = RouteDetailRowSchema.nullable().parse(result.rows?.[0] ?? null); + const row = RouteDetailRowSchema.nullable().parse(firstQueryRow(result) ?? null); if (!row) return status(404, { error: 'Trail not found' }); let geometry: unknown = null; @@ -233,7 +234,7 @@ export const trailsRoutes = new Elysia({ prefix: '/trails' }) WHERE osm_id = ${osmId} `); - const row = RouteSearchRowSchema.nullable().parse(result.rows?.[0] ?? null); + const row = RouteSearchRowSchema.nullable().parse(firstQueryRow(result) ?? null); if (!row) return status(404, { error: 'Trail not found' }); return { diff --git a/packages/api/src/routes/trips/index.ts b/packages/api/src/routes/trips/index.ts index 455d63a359..44f172a1d9 100644 --- a/packages/api/src/routes/trips/index.ts +++ b/packages/api/src/routes/trips/index.ts @@ -1,6 +1,6 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; -import { trips } from '@packrat/db'; +import { trips } from '@packrat/db/schema'; import { CreateTripBodySchema, TripSchema, UpdateTripBodySchema } from '@packrat/schemas/trips'; import { and, eq } from 'drizzle-orm'; import { Elysia, NotFoundError, status } from 'elysia'; diff --git a/packages/api/src/routes/user/index.ts b/packages/api/src/routes/user/index.ts index 422071d000..895bc9d1b6 100644 --- a/packages/api/src/routes/user/index.ts +++ b/packages/api/src/routes/user/index.ts @@ -1,7 +1,7 @@ import { createDb } from '@packrat/api/db'; import { authPlugin } from '@packrat/api/middleware/auth'; import { captureApiException } from '@packrat/api/utils/sentry'; -import { users } from '@packrat/db'; +import { users } from '@packrat/db/schema'; import { ErrorResponseSchema } from '@packrat/schemas/shared'; import { GetPreferencesResponseSchema, diff --git a/packages/api/src/routes/weather.ts b/packages/api/src/routes/weather.ts index 083436e358..7584d5a0af 100644 --- a/packages/api/src/routes/weather.ts +++ b/packages/api/src/routes/weather.ts @@ -1,4 +1,9 @@ import { authPlugin } from '@packrat/api/middleware/auth'; +import { + buildStubForecast, + defaultStubLocation, + searchStubLocations, +} from '@packrat/api/services/weatherService'; import { getEnv } from '@packrat/api/utils/env-validation'; import { captureApiException } from '@packrat/api/utils/sentry'; import { isString } from '@packrat/guards'; @@ -18,6 +23,8 @@ import { ZodError } from 'zod'; const WEATHER_API_BASE_URL = 'https://api.weatherapi.com/v1'; +const isE2EWeatherStubKey = (key?: string) => key?.startsWith('weather-e2e-stub-') === true; + export const weatherRoutes = new Elysia({ prefix: '/weather' }) .model({ 'weather.ForecastResponse': WeatherAPIForecastResponseSchema, @@ -33,6 +40,17 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) return status(400, { error: 'Query parameter is required' }); } + if (isE2EWeatherStubKey(WEATHER_API_KEY)) { + return searchStubLocations(q).map((item) => ({ + id: item.id, + name: item.name, + region: item.region, + country: item.country, + lat: item.lat, + lon: item.lon, + })); + } + try { const response = await fetch( `${WEATHER_API_BASE_URL}/search.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(q)}`, @@ -83,6 +101,19 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) }); } + if (isE2EWeatherStubKey(WEATHER_API_KEY)) { + return [ + { + id: defaultStubLocation.id, + name: defaultStubLocation.name, + region: defaultStubLocation.region, + country: defaultStubLocation.country, + lat: defaultStubLocation.lat, + lon: defaultStubLocation.lon, + }, + ]; + } + try { const q = `${latitude.toFixed(6)},${longitude.toFixed(6)}`; const response = await fetch( @@ -156,6 +187,10 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) return status(400, { error: 'Valid location ID is required' }); } + if (isE2EWeatherStubKey(WEATHER_API_KEY)) { + return WeatherAPIForecastResponseSchema.parse(buildStubForecast(id)); + } + try { const q = `id:${id}`; const response = await fetch( @@ -220,6 +255,15 @@ export const weatherRoutes = new Elysia({ prefix: '/weather' }) // Schema enforces z.string().min(2); Elysia rejects shorter values // before the handler runs. const q = query.q; + + if (isE2EWeatherStubKey(WEATHER_API_KEY)) { + const firstMatch = first(searchStubLocations(q)); + if (!firstMatch) { + return status(404, { error: `No weather location matched "${q}"` }); + } + return WeatherAPIForecastResponseSchema.parse(buildStubForecast(firstMatch.id)); + } + try { const searchResponse = await fetch( `${WEATHER_API_BASE_URL}/search.json?key=${WEATHER_API_KEY}&q=${encodeURIComponent(q)}`, diff --git a/packages/api/src/services/__tests__/embeddingService.test.ts b/packages/api/src/services/__tests__/embeddingService.test.ts index d412a73bb5..b689e74493 100644 --- a/packages/api/src/services/__tests__/embeddingService.test.ts +++ b/packages/api/src/services/__tests__/embeddingService.test.ts @@ -136,6 +136,20 @@ describe('embeddingService', () => { expect(first).toHaveLength(1536); expect(first).toEqual(second); }); + + it('returns deterministic embeddings for the local sk-test key', async () => { + const { embed } = await import('ai'); + const mockEmbed = embed as ReturnType; + + const result = await generateEmbedding({ + ...baseParams, + openAiApiKey: 'sk-test', + value: 'local e2e item', + }); + + expect(result).toHaveLength(1536); + expect(mockEmbed).not.toHaveBeenCalled(); + }); }); describe('generateManyEmbeddings', () => { diff --git a/packages/api/src/services/__tests__/entitlementsService.test.ts b/packages/api/src/services/__tests__/entitlementsService.test.ts index 356c147461..2d581ab97b 100644 --- a/packages/api/src/services/__tests__/entitlementsService.test.ts +++ b/packages/api/src/services/__tests__/entitlementsService.test.ts @@ -31,6 +31,7 @@ vi.mock('drizzle-orm', () => ({ gt: (col: unknown, val: unknown) => ({ gt: [col, val] }), isNull: (col: unknown) => ({ isNull: col }), or: (...conds: unknown[]) => ({ or: conds }), + relations: vi.fn(() => ({})), })); import { diff --git a/packages/api/src/services/__tests__/featureAccessService.test.ts b/packages/api/src/services/__tests__/featureAccessService.test.ts index 7734f642b3..19c38cf09f 100644 --- a/packages/api/src/services/__tests__/featureAccessService.test.ts +++ b/packages/api/src/services/__tests__/featureAccessService.test.ts @@ -43,7 +43,10 @@ const mocks = vi.hoisted(() => { vi.mock('@packrat/api/db', () => ({ createDb: mocks.createDb })); vi.mock('@packrat/api/utils/sentry', () => ({ captureApiException: mocks.captureApiException })); vi.mock('@packrat/db', () => ({ featureAccess: { key: 'key' } })); -vi.mock('drizzle-orm', () => ({ eq: vi.fn((col, val) => ({ col, val })) })); +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col, val) => ({ col, val })), + relations: vi.fn(() => ({})), +})); import { canAccessFeature, diff --git a/packages/api/src/services/__tests__/featureFlagsService.test.ts b/packages/api/src/services/__tests__/featureFlagsService.test.ts index 91591892d2..531197d586 100644 --- a/packages/api/src/services/__tests__/featureFlagsService.test.ts +++ b/packages/api/src/services/__tests__/featureFlagsService.test.ts @@ -34,7 +34,10 @@ const mocks = vi.hoisted(() => { vi.mock('@packrat/api/db', () => ({ createDb: mocks.createDb })); vi.mock('@packrat/api/utils/sentry', () => ({ captureApiException: mocks.captureApiException })); vi.mock('@packrat/db', () => ({ featureFlags: { key: 'key' } })); -vi.mock('drizzle-orm', () => ({ eq: vi.fn((col, val) => ({ col, val })) })); +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col, val) => ({ col, val })), + relations: vi.fn(() => ({})), +})); import { APP_CONFIG, FeatureFlag } from '@packrat/config'; import { diff --git a/packages/api/src/services/__tests__/passwordResetService.test.ts b/packages/api/src/services/__tests__/passwordResetService.test.ts index d0486d15a0..779a2db3bc 100644 --- a/packages/api/src/services/__tests__/passwordResetService.test.ts +++ b/packages/api/src/services/__tests__/passwordResetService.test.ts @@ -50,10 +50,8 @@ vi.mock('@packrat/api/utils/email', () => ({ sendPasswordResetEmail: mocks.sendPasswordResetEmail, })); vi.mock('@packrat/api/utils/auth', () => ({ - timingSafeEqual: mocks.timingSafeEqual, -})); -vi.mock('@better-auth/utils/password', () => ({ hashPassword: mocks.hashPassword, + timingSafeEqual: mocks.timingSafeEqual, })); vi.mock('@packrat/db', () => ({ users: {}, @@ -64,6 +62,7 @@ vi.mock('drizzle-orm', () => ({ and: vi.fn(), eq: vi.fn(), gt: vi.fn(), + relations: vi.fn(() => ({})), })); import { requestPasswordReset, verifyOtpAndResetPassword } from '../passwordResetService'; diff --git a/packages/api/src/services/__tests__/userService.test.ts b/packages/api/src/services/__tests__/userService.test.ts index 3c2e25f2d5..f817d504d0 100644 --- a/packages/api/src/services/__tests__/userService.test.ts +++ b/packages/api/src/services/__tests__/userService.test.ts @@ -29,7 +29,10 @@ const mocks = vi.hoisted(() => { vi.mock('@packrat/api/db', () => ({ createDb: mocks.createDb })); vi.mock('@packrat/api/utils/auth', () => ({ hashPassword: mocks.hashPassword })); vi.mock('@packrat/db', () => ({ users: { email: 'email', id: 'id' } })); -vi.mock('drizzle-orm', () => ({ eq: vi.fn() })); +vi.mock('drizzle-orm', () => ({ + eq: vi.fn(), + relations: vi.fn(() => ({})), +})); import { UserService } from '../userService'; @@ -69,7 +72,7 @@ describe('UserService', () => { await service.findByEmail('ALICE@EXAMPLE.COM'); // UserService calls eq(users.email, email.toLowerCase()), which is called with the lowercased value const { eq } = await import('drizzle-orm'); - const { users } = await import('@packrat/db'); + const { users } = await import('@packrat/db/schema'); expect(vi.mocked(eq)).toHaveBeenCalledWith(users.email, 'alice@example.com'); }); }); diff --git a/packages/api/src/services/aiService.ts b/packages/api/src/services/aiService.ts index 3ae247f750..19bf9945d8 100644 --- a/packages/api/src/services/aiService.ts +++ b/packages/api/src/services/aiService.ts @@ -3,7 +3,6 @@ import { createPerplexityAIProvider } from '@packrat/api/utils/ai/provider'; import type { Env } from '@packrat/api/utils/env-validation'; import { getEnv } from '@packrat/api/utils/env-validation'; import { isFunction } from '@packrat/guards'; -import { generateText } from 'ai'; interface SearchResult { answer: string; @@ -35,6 +34,7 @@ export class AIService { }); try { + const { generateText } = await import('ai'); const resp = await generateText({ model: perplexity(DEFAULT_MODELS.PERPLEXITY_SEARCH), system: WEB_SEARCH_SYSTEM_PROMPT, diff --git a/packages/api/src/services/catalogService.ts b/packages/api/src/services/catalogService.ts index b240f1e29e..6847cc6240 100644 --- a/packages/api/src/services/catalogService.ts +++ b/packages/api/src/services/catalogService.ts @@ -7,7 +7,7 @@ import { catalogItemEtlJobs, catalogItems, type NewCatalogItem, -} from '@packrat/db'; +} from '@packrat/db/schema'; import { and, asc, diff --git a/packages/api/src/services/dbMetricsService.ts b/packages/api/src/services/dbMetricsService.ts index a92e4875ae..5a1bda75df 100644 --- a/packages/api/src/services/dbMetricsService.ts +++ b/packages/api/src/services/dbMetricsService.ts @@ -1,4 +1,5 @@ import { createReadOnlyDb } from '@packrat/api/db'; +import { firstQueryRow, queryRows } from '@packrat/api/db/queryRows'; import { sql } from 'drizzle-orm'; /** @@ -95,7 +96,7 @@ export class DbMetricsService { current_database() AS name, pg_database_size(current_database())::text AS size_bytes `); - const row = rows.rows[0]; + const row = firstQueryRow(rows); return { name: row?.name ?? 'unknown', sizeBytes: Number(row?.size_bytes ?? 0), @@ -110,7 +111,7 @@ export class DbMetricsService { FROM pg_stat_database WHERE datname = current_database() `); - return rows.rows[0]?.stats_reset ?? null; + return firstQueryRow(rows)?.stats_reset ?? null; } private async fetchTables(): Promise { @@ -168,7 +169,7 @@ export class DbMetricsService { ORDER BY pg_total_relation_size(c.oid) DESC `); - return rows.rows.map((r) => ({ + return queryRows(rows).map((r) => ({ name: r.name, estimatedRows: Number(r.est_rows), heapBytes: Number(r.heap_bytes), @@ -213,7 +214,7 @@ export class DbMetricsService { ORDER BY pg_relation_size(s.indexrelid) DESC `); - return rows.rows.map((r) => ({ + return queryRows(rows).map((r) => ({ table: r.table, name: r.name, bytes: Number(r.bytes), diff --git a/packages/api/src/services/embeddingService.ts b/packages/api/src/services/embeddingService.ts index d0b11b8ac5..be73e7157c 100644 --- a/packages/api/src/services/embeddingService.ts +++ b/packages/api/src/services/embeddingService.ts @@ -1,7 +1,6 @@ import { DEFAULT_MODELS } from '@packrat/api/utils/ai/models'; import { type AIProvider, createAIProvider } from '@packrat/api/utils/ai/provider'; import type { Env } from '@packrat/api/utils/env-validation'; -import { embed, embedMany } from 'ai'; // ── Embedding text normalization ────────────────────────────────────── const NEWLINE = /\n/g; @@ -20,7 +19,8 @@ type GenerateEmbeddingParams = GenerateEmbeddingBaseParams & { value: string; }; -const isE2EStubKey = (key?: string) => key?.startsWith('sk-e2e-stub-') === true; +const isE2EStubKey = (key?: string) => + key === 'sk-test' || key?.startsWith('sk-e2e-stub-') === true; const deterministicEmbedding = (value: string): number[] => { let hash = 2166136261; @@ -54,6 +54,7 @@ export const generateEmbedding = async ( } const aiProvider = createAIProvider(providerConfig); + const { embed } = await import('ai'); const { embedding } = await embed({ model: aiProvider.embedding(DEFAULT_MODELS.OPENAI_EMBEDDING), @@ -83,6 +84,7 @@ export const generateManyEmbeddings = async ( } const aiProvider = createAIProvider(providerConfig); + const { embedMany } = await import('ai'); const { embeddings } = await embedMany({ model: aiProvider.embedding(DEFAULT_MODELS.OPENAI_EMBEDDING), diff --git a/packages/api/src/services/entitlementsService.ts b/packages/api/src/services/entitlementsService.ts index ff0f3a9b8e..f6ed7133a3 100644 --- a/packages/api/src/services/entitlementsService.ts +++ b/packages/api/src/services/entitlementsService.ts @@ -1,7 +1,7 @@ import { createDb } from '@packrat/api/db'; import { captureApiException } from '@packrat/api/utils/sentry'; import { PACKRAT_PRO_ENTITLEMENT } from '@packrat/config'; -import { entitlements } from '@packrat/db'; +import { entitlements } from '@packrat/db/schema'; import { asNumber, asString, isArray, isString } from '@packrat/guards'; import { and, eq, gt, isNull, or } from 'drizzle-orm'; diff --git a/packages/api/src/services/etl/CatalogItemValidator.ts b/packages/api/src/services/etl/CatalogItemValidator.ts index b3b178063d..25664016ca 100644 --- a/packages/api/src/services/etl/CatalogItemValidator.ts +++ b/packages/api/src/services/etl/CatalogItemValidator.ts @@ -1,5 +1,5 @@ import type { ValidatedCatalogItem } from '@packrat/api/types/etl'; -import type { NewCatalogItem } from '@packrat/db'; +import type { NewCatalogItem } from '@packrat/db/schema'; import { isNumber, isString } from '@packrat/guards'; import type { ValidationError } from '@packrat/schemas/validation'; diff --git a/packages/api/src/services/etl/mergeItemsBySku.ts b/packages/api/src/services/etl/mergeItemsBySku.ts index 21642f3eff..9b63670be1 100644 --- a/packages/api/src/services/etl/mergeItemsBySku.ts +++ b/packages/api/src/services/etl/mergeItemsBySku.ts @@ -1,4 +1,4 @@ -import type { NewCatalogItem } from '@packrat/db'; +import type { NewCatalogItem } from '@packrat/db/schema'; /** * Merges all occurrences of each SKU into a single item diff --git a/packages/api/src/services/etl/processCatalogEtl.ts b/packages/api/src/services/etl/processCatalogEtl.ts index 22ac80fb94..a4bba3a837 100644 --- a/packages/api/src/services/etl/processCatalogEtl.ts +++ b/packages/api/src/services/etl/processCatalogEtl.ts @@ -2,7 +2,7 @@ import { createDbClient } from '@packrat/api/db'; import { mapCsvRowToItem } from '@packrat/api/utils/csv-utils'; import type { Env } from '@packrat/api/utils/env-validation'; import { isJsonlFile, mapJsonRowToItem } from '@packrat/api/utils/json-utils'; -import { etlJobs, type NewCatalogItem, type NewInvalidItemLog } from '@packrat/db'; +import { etlJobs, type NewCatalogItem, type NewInvalidItemLog } from '@packrat/db/schema'; import { toRecord } from '@packrat/guards'; import { safeJsonParse } from '@packrat/utils'; import { parse } from 'csv-parse'; diff --git a/packages/api/src/services/etl/processLogsBatch.ts b/packages/api/src/services/etl/processLogsBatch.ts index ceace7db89..3f485428c5 100644 --- a/packages/api/src/services/etl/processLogsBatch.ts +++ b/packages/api/src/services/etl/processLogsBatch.ts @@ -2,7 +2,7 @@ import { createDbClient } from '@packrat/api/db'; import type { Env } from '@packrat/api/utils/env-validation'; import { logger } from '@packrat/api/utils/logger'; import { record } from '@packrat/api/utils/sentry'; -import { invalidItemLogs, type NewInvalidItemLog } from '@packrat/db'; +import { invalidItemLogs, type NewInvalidItemLog } from '@packrat/db/schema'; import { updateEtlJobProgress } from './updateEtlJobProgress'; export async function processLogsBatch({ diff --git a/packages/api/src/services/etl/processValidItemsBatch.ts b/packages/api/src/services/etl/processValidItemsBatch.ts index 25f33abcac..2de925401f 100644 --- a/packages/api/src/services/etl/processValidItemsBatch.ts +++ b/packages/api/src/services/etl/processValidItemsBatch.ts @@ -2,7 +2,7 @@ import { createDbClient } from '@packrat/api/db'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; import type { Env } from '@packrat/api/utils/env-validation'; import { logger } from '@packrat/api/utils/logger'; -import { etlJobs, type NewCatalogItem } from '@packrat/db'; +import { etlJobs, type NewCatalogItem } from '@packrat/db/schema'; import { isString } from '@packrat/guards'; import { eq, sql } from 'drizzle-orm'; import { CatalogService } from '../catalogService'; diff --git a/packages/api/src/services/etl/updateEtlJobProgress.ts b/packages/api/src/services/etl/updateEtlJobProgress.ts index a30d2041a3..78e9b13565 100644 --- a/packages/api/src/services/etl/updateEtlJobProgress.ts +++ b/packages/api/src/services/etl/updateEtlJobProgress.ts @@ -1,6 +1,6 @@ import { createDbClient } from '@packrat/api/db'; import type { Env } from '@packrat/api/utils/env-validation'; -import { etlJobs } from '@packrat/db'; +import { etlJobs } from '@packrat/db/schema'; import { eq, sql } from 'drizzle-orm'; export async function updateEtlJobProgress({ diff --git a/packages/api/src/services/featureAccessService.ts b/packages/api/src/services/featureAccessService.ts index 7b048a6ad5..688989a3db 100644 --- a/packages/api/src/services/featureAccessService.ts +++ b/packages/api/src/services/featureAccessService.ts @@ -1,7 +1,7 @@ import { createDb } from '@packrat/api/db'; import { captureApiException } from '@packrat/api/utils/sentry'; import { hasFeatureAccess } from '@packrat/config'; -import { type FeatureAccess, featureAccess } from '@packrat/db'; +import { type FeatureAccess, featureAccess } from '@packrat/db/schema'; import { eq } from 'drizzle-orm'; /** diff --git a/packages/api/src/services/featureFlagsService.ts b/packages/api/src/services/featureFlagsService.ts index e996e07119..24bc8139a8 100644 --- a/packages/api/src/services/featureFlagsService.ts +++ b/packages/api/src/services/featureFlagsService.ts @@ -1,7 +1,7 @@ import { createDb } from '@packrat/api/db'; import { captureApiException } from '@packrat/api/utils/sentry'; import { APP_CONFIG, FeatureFlag } from '@packrat/config'; -import { featureFlags } from '@packrat/db'; +import { featureFlags } from '@packrat/db/schema'; import { eq } from 'drizzle-orm'; // All flag keys known to the codebase. Exported so the admin route can build diff --git a/packages/api/src/services/imageDetectionService.ts b/packages/api/src/services/imageDetectionService.ts index 7d63cee306..98338ed0d3 100644 --- a/packages/api/src/services/imageDetectionService.ts +++ b/packages/api/src/services/imageDetectionService.ts @@ -1,8 +1,7 @@ import { DEFAULT_MODELS } from '@packrat/api/utils/ai/models'; import { createAIProvider } from '@packrat/api/utils/ai/provider'; import { getEnv } from '@packrat/api/utils/env-validation'; -import type { CatalogItem } from '@packrat/db'; -import { generateObject } from 'ai'; +import type { CatalogItem } from '@packrat/db/schema'; import { z } from 'zod'; import { CatalogService } from './catalogService'; @@ -72,6 +71,7 @@ export class ImageDetectionService { cloudflareAiBinding: AI, }); + const { generateObject } = await import('ai'); const { object } = await generateObject({ model: aiProvider(DEFAULT_MODELS.OPENAI_CHAT), schema: imageAnalysisSchema, diff --git a/packages/api/src/services/packItemService.ts b/packages/api/src/services/packItemService.ts index 0a9cf13fcc..24c5bd4b5b 100644 --- a/packages/api/src/services/packItemService.ts +++ b/packages/api/src/services/packItemService.ts @@ -1,5 +1,5 @@ import { createDb } from '@packrat/api/db'; -import { packItems } from '@packrat/db'; +import { packItems } from '@packrat/db/schema'; import { and, eq } from 'drizzle-orm'; export class PackItemService { diff --git a/packages/api/src/services/packService.ts b/packages/api/src/services/packService.ts index de51e9b5c0..ba62e93860 100644 --- a/packages/api/src/services/packService.ts +++ b/packages/api/src/services/packService.ts @@ -4,8 +4,7 @@ import { createAIProvider } from '@packrat/api/utils/ai/provider'; import { getEnv } from '@packrat/api/utils/env-validation'; import { setQueryTag } from '@packrat/api/utils/queryMetrics'; import { PACK_CATEGORIES } from '@packrat/constants'; -import { type NewPack, type NewPackItem, packItems, packs } from '@packrat/db'; -import { generateObject } from 'ai'; +import { type NewPack, type NewPackItem, packItems, packs } from '@packrat/db/schema'; import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { computePackWeights } from '../utils/compute-pack'; @@ -152,6 +151,7 @@ export class PackService { cloudflareAiBinding: AI, }); + const { generateObject } = await import('ai'); const { object } = await generateObject({ model: aiProvider(DEFAULT_MODELS.OPENAI_CHAT), output: 'array', diff --git a/packages/api/src/services/passwordResetService.ts b/packages/api/src/services/passwordResetService.ts index 399708c635..aa61c6b0c0 100644 --- a/packages/api/src/services/passwordResetService.ts +++ b/packages/api/src/services/passwordResetService.ts @@ -1,8 +1,7 @@ -import { hashPassword } from '@better-auth/utils/password'; import { createDb } from '@packrat/api/db'; -import { timingSafeEqual } from '@packrat/api/utils/auth'; +import { hashPassword, timingSafeEqual } from '@packrat/api/utils/auth'; import { sendPasswordResetEmail } from '@packrat/api/utils/email'; -import { account, users, verification } from '@packrat/db'; +import { account, users, verification } from '@packrat/db/schema'; import { and, eq, gt } from 'drizzle-orm'; const OTP_LENGTH = 6; diff --git a/packages/api/src/services/retention/invalidLogRetention.ts b/packages/api/src/services/retention/invalidLogRetention.ts index 50295d4806..1bfc3d3322 100644 --- a/packages/api/src/services/retention/invalidLogRetention.ts +++ b/packages/api/src/services/retention/invalidLogRetention.ts @@ -6,7 +6,7 @@ import { createDbClient } from '@packrat/api/db'; import type { Env } from '@packrat/api/utils/env-validation'; import { record } from '@packrat/api/utils/sentry'; -import { invalidItemLogs } from '@packrat/db'; +import { invalidItemLogs } from '@packrat/db/schema'; import { inArray, lt, sql } from 'drizzle-orm'; const DEFAULT_RETENTION_DAYS = 90; diff --git a/packages/api/src/services/trails.ts b/packages/api/src/services/trails.ts index e9a6299e70..d2eea77b13 100644 --- a/packages/api/src/services/trails.ts +++ b/packages/api/src/services/trails.ts @@ -1,4 +1,5 @@ import type { createOsmDb } from '@packrat/api/db'; +import { firstQueryRow } from '@packrat/api/db/queryRows'; import type { OsmMember } from '@packrat/schemas/trails'; import { safeJsonParse } from '@packrat/utils'; import { sql } from 'drizzle-orm'; @@ -49,7 +50,7 @@ export async function stitchRouteGeometry({ const row = z .object({ geojson: z.string().nullable() }) .nullable() - .parse(result.rows?.[0] ?? null); + .parse(firstQueryRow(result) ?? null); if (!row?.geojson) return null; try { diff --git a/packages/api/src/services/userService.ts b/packages/api/src/services/userService.ts index 626f1feb5c..1eed3d7113 100644 --- a/packages/api/src/services/userService.ts +++ b/packages/api/src/services/userService.ts @@ -1,6 +1,6 @@ import { createDb } from '@packrat/api/db'; import { hashPassword } from '@packrat/api/utils/auth'; -import { type User, users } from '@packrat/db'; +import { type User, users } from '@packrat/db/schema'; import { eq } from 'drizzle-orm'; export type CreateUserInput = { diff --git a/packages/api/src/services/weatherService.ts b/packages/api/src/services/weatherService.ts index 23e20f5fc2..55b5c79cdb 100644 --- a/packages/api/src/services/weatherService.ts +++ b/packages/api/src/services/weatherService.ts @@ -1,6 +1,145 @@ import { getEnv } from '@packrat/api/utils/env-validation'; import { captureApiException } from '@packrat/api/utils/sentry'; -import { OpenWeatherResponseSchema } from '@packrat/schemas/weather'; +import { + OpenWeatherResponseSchema, + type WeatherAPIForecastResponse, + type WeatherAPISearchResponse, +} from '@packrat/schemas/weather'; + +const COORDINATE_QUERY_REGEX = /^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/; + +const stubCondition = { + text: 'Partly cloudy', + icon: '//cdn.weatherapi.com/weather/64x64/day/116.png', + code: 1003, +}; + +const buildStubCurrent = (tempF = 72): WeatherAPIForecastResponse['current'] => ({ + last_updated: '2026-07-16 12:00', + temp_c: Math.round(((tempF - 32) * 5) / 9), + temp_f: tempF, + condition: stubCondition, + wind_mph: 7, + wind_kph: 11, + wind_degree: 240, + wind_dir: 'WSW', + pressure_mb: 1016, + pressure_in: 30.0, + precip_mm: 0, + precip_in: 0, + humidity: 32, + cloud: 25, + feelslike_c: Math.round(((tempF - 32) * 5) / 9), + feelslike_f: tempF, + vis_km: 16, + vis_miles: 10, + uv: 5, + is_day: 1, + gust_mph: 14, + gust_kph: 22, +}); + +const buildStubForecastDay = ( + dayOffset: number, +): WeatherAPIForecastResponse['forecast']['forecastday'][number] => { + const date = new Date(Date.UTC(2026, 6, 16 + dayOffset)); + const highF = 78 + (dayOffset % 3); + const lowF = 55 + (dayOffset % 2); + return { + date: date.toISOString().slice(0, 10), + date_epoch: Math.floor(date.getTime() / 1000), + day: { + maxtemp_c: Math.round(((highF - 32) * 5) / 9), + maxtemp_f: highF, + mintemp_c: Math.round(((lowF - 32) * 5) / 9), + mintemp_f: lowF, + avgtemp_c: Math.round((((highF + lowF) / 2 - 32) * 5) / 9), + avgtemp_f: (highF + lowF) / 2, + maxwind_mph: 18, + maxwind_kph: 29, + totalprecip_mm: dayOffset === 2 ? 1.2 : 0, + totalprecip_in: dayOffset === 2 ? 0.05 : 0, + totalsnow_cm: 0, + avghumidity: 35, + avgvis_km: 16, + avgvis_miles: 10, + uv: 5, + condition: stubCondition, + daily_chance_of_rain: dayOffset === 2 ? 30 : 5, + daily_chance_of_snow: 0, + }, + astro: { + sunrise: '05:48 AM', + sunset: '08:27 PM', + moonrise: '10:15 PM', + moonset: '07:12 AM', + moon_phase: 'Waxing Crescent', + moon_illumination: 18, + }, + hour: [], + }; +}; + +type StubWeatherLocation = WeatherAPISearchResponse[number]; + +export const defaultStubLocation: StubWeatherLocation = { + id: 5419384, + name: 'Denver', + region: 'Colorado', + country: 'United States', + lat: 39.74, + lon: -104.98, + url: 'denver-colorado-united-states-of-america', +}; + +const stubLocations: [StubWeatherLocation, ...StubWeatherLocation[]] = [ + defaultStubLocation, + { + id: 5400000, + name: 'Yosemite Valley', + region: 'California', + country: 'United States', + lat: 37.75, + lon: -119.59, + url: 'yosemite-valley-california-united-states-of-america', + }, +] satisfies [StubWeatherLocation, ...StubWeatherLocation[]]; + +export const getStubLocation = (id?: number) => + stubLocations.find((location) => location.id === id) ?? defaultStubLocation; + +export const buildStubForecast = (id?: number): WeatherAPIForecastResponse => { + const location = getStubLocation(id); + return { + location: { + id: location.id, + name: location.name, + region: location.region, + country: location.country, + lat: location.lat, + lon: location.lon, + tz_id: location.name === 'Denver' ? 'America/Denver' : 'America/Los_Angeles', + localtime_epoch: 1784246400, + localtime: '2026-07-16 12:00', + }, + current: buildStubCurrent(location.name === 'Denver' ? 72 : 68), + forecast: { + forecastday: Array.from({ length: 10 }, (_, index) => buildStubForecastDay(index)), + }, + alerts: { alert: [] }, + }; +}; + +export const searchStubLocations = (query: string) => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return []; + if (COORDINATE_QUERY_REGEX.test(normalizedQuery)) return [defaultStubLocation]; + return stubLocations.filter((location) => + `${location.name} ${location.region} ${location.country}` + .toLowerCase() + .includes(normalizedQuery), + ); +}; type WeatherData = { location: string; diff --git a/packages/api/src/services/wildlifeIdentificationService.ts b/packages/api/src/services/wildlifeIdentificationService.ts index 7e365d6d6d..18fee82469 100644 --- a/packages/api/src/services/wildlifeIdentificationService.ts +++ b/packages/api/src/services/wildlifeIdentificationService.ts @@ -1,7 +1,6 @@ import { DEFAULT_MODELS } from '@packrat/api/utils/ai/models'; import { createAIProvider } from '@packrat/api/utils/ai/provider'; import { getEnv } from '@packrat/api/utils/env-validation'; -import { generateObject } from 'ai'; import { z } from 'zod'; const SPECIES_IDENTIFICATION_SYSTEM_PROMPT = `You are an expert naturalist and wildlife biologist specializing in plant and animal identification. @@ -75,6 +74,7 @@ export class WildlifeIdentificationService { }); try { + const { generateObject } = await import('ai'); const { object } = await generateObject({ model: aiProvider(DEFAULT_MODELS.OPENAI_CHAT), schema: identificationResponseSchema, diff --git a/packages/api/src/types/etl.ts b/packages/api/src/types/etl.ts index 5e588cc69e..07b5178efe 100644 --- a/packages/api/src/types/etl.ts +++ b/packages/api/src/types/etl.ts @@ -1,4 +1,4 @@ -import type { NewCatalogItem } from '@packrat/db'; +import type { NewCatalogItem } from '@packrat/db/schema'; import type { ValidationError } from '@packrat/schemas/validation'; export interface ValidatedCatalogItem { diff --git a/packages/api/src/utils/DbUtils.ts b/packages/api/src/utils/DbUtils.ts index 060382ce53..ace8fe4c91 100644 --- a/packages/api/src/utils/DbUtils.ts +++ b/packages/api/src/utils/DbUtils.ts @@ -1,5 +1,6 @@ import { createDb } from '@packrat/api/db'; -import { catalogItems, packs } from '@packrat/db'; +import { queryRows } from '@packrat/api/db/queryRows'; +import { catalogItems, packs } from '@packrat/db/schema'; import { and, arrayOverlaps, eq, inArray, type SQL, sql } from 'drizzle-orm'; // Get pack details from the database @@ -101,7 +102,7 @@ export async function getSchemaInfo() { `; const result = await db.tag('utils.getSchemaInfo').execute(sql.raw(schemaQuery)); - return result.rows + return queryRows(result) .map((row) => `${row.table_name} (\n ${row.columns}\n );`) .join('\n\n'); } catch (error) { diff --git a/packages/api/src/utils/__tests__/compute-pack.test.ts b/packages/api/src/utils/__tests__/compute-pack.test.ts index 00d1f1b153..54f1676262 100644 --- a/packages/api/src/utils/__tests__/compute-pack.test.ts +++ b/packages/api/src/utils/__tests__/compute-pack.test.ts @@ -1,4 +1,4 @@ -import type { PackItem, PackWithItems } from '@packrat/db'; +import type { PackItem, PackWithItems } from '@packrat/db/schema'; import { describe, expect, it } from 'vitest'; import { computePackBreakdown, computePacksWeights, computePackWeights } from '../compute-pack'; diff --git a/packages/api/src/utils/__tests__/env-validation.test.ts b/packages/api/src/utils/__tests__/env-validation.test.ts index 0903f11b32..6a9b4b5b80 100644 --- a/packages/api/src/utils/__tests__/env-validation.test.ts +++ b/packages/api/src/utils/__tests__/env-validation.test.ts @@ -130,7 +130,9 @@ describe('env-validation', () => { const result = apiEnvSchema.safeParse(makeRawEnv({ OPENAI_API_KEY: undefined })); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.message).toContain('Required'); + expect(result.error.issues).toEqual([ + expect.objectContaining({ path: ['OPENAI_API_KEY'] }), + ]); } }); @@ -140,7 +142,9 @@ describe('env-validation', () => { ); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.message).toContain('Required'); + expect(result.error.issues).toEqual([ + expect.objectContaining({ path: ['GOOGLE_GENERATIVE_AI_API_KEY'] }), + ]); } }); diff --git a/packages/api/src/utils/ai/tools.ts b/packages/api/src/utils/ai/tools.ts index 179b576f23..6b35674b91 100644 --- a/packages/api/src/utils/ai/tools.ts +++ b/packages/api/src/utils/ai/tools.ts @@ -1,9 +1,9 @@ import { AIService, CatalogService, WeatherService } from '@packrat/api/services'; import { executeSqlAiTool } from '@packrat/api/services/executeSqlAiTool'; -import { tool } from 'ai'; import { z } from 'zod'; -export function createTools(userId: string) { +export async function createTools(userId: string) { + const { tool } = await import('ai'); const weatherService = new WeatherService(); const catalogService = new CatalogService(); const aiService = new AIService(); diff --git a/packages/api/src/utils/compute-pack.ts b/packages/api/src/utils/compute-pack.ts index b7f48949a5..a94fc7bd36 100644 --- a/packages/api/src/utils/compute-pack.ts +++ b/packages/api/src/utils/compute-pack.ts @@ -3,7 +3,7 @@ import type { PackForWeights, PackItemForBreakdown, PackItemForWeights, -} from '@packrat/db'; +} from '@packrat/db/projections'; import type { WeightUnit } from '@packrat/units'; import { displayWeight, normalize, parseWeightUnit } from '@packrat/units'; diff --git a/packages/api/src/utils/csv-utils.ts b/packages/api/src/utils/csv-utils.ts index 0138d1caf8..c808775377 100644 --- a/packages/api/src/utils/csv-utils.ts +++ b/packages/api/src/utils/csv-utils.ts @@ -1,4 +1,4 @@ -import type { NewCatalogItem } from '@packrat/db'; +import type { NewCatalogItem } from '@packrat/db/schema'; import { isString } from '@packrat/guards'; import { AvailabilitySchema, WeightUnitSchema } from '@packrat/schemas/constants'; import { safeJsonParse } from '@packrat/utils'; diff --git a/packages/api/src/utils/email.ts b/packages/api/src/utils/email.ts index 0dba143edf..480fc6e7bc 100644 --- a/packages/api/src/utils/email.ts +++ b/packages/api/src/utils/email.ts @@ -1,5 +1,4 @@ import { getEnv } from '@packrat/api/utils/env-validation'; -import { Resend } from 'resend'; export async function sendEmail({ to, @@ -11,6 +10,7 @@ export async function sendEmail({ html: string; }): Promise { const { RESEND_API_KEY, EMAIL_FROM } = getEnv(); + const { Resend } = await import('resend'); const resendClient = new Resend(RESEND_API_KEY); await resendClient.emails.send({ diff --git a/packages/api/src/utils/embeddingHelper.ts b/packages/api/src/utils/embeddingHelper.ts index 4578387219..3a800d67c7 100644 --- a/packages/api/src/utils/embeddingHelper.ts +++ b/packages/api/src/utils/embeddingHelper.ts @@ -1,4 +1,4 @@ -import type { CatalogItem, PackItem } from '@packrat/db'; +import type { CatalogItem, PackItem } from '@packrat/db/schema'; import { isObject, isString } from '@packrat/guards'; type ItemForEmbedding = Partial | Partial; diff --git a/packages/api/src/utils/env-validation.ts b/packages/api/src/utils/env-validation.ts index 4358a42efd..0ed5812f35 100644 --- a/packages/api/src/utils/env-validation.ts +++ b/packages/api/src/utils/env-validation.ts @@ -236,6 +236,25 @@ const testEnvSchema = apiEnvObjectSchema type ValidatedAppEnv = z.infer; +export function isLocalE2EApiEnv(input: { + databaseUrl: string; + e2eUserId?: string; + openAiApiKey?: string; + requireE2EUser?: boolean; + requireStubOpenAI?: boolean; +}): boolean { + const databaseIsLocal = + input.databaseUrl.includes('127.0.0.1') || input.databaseUrl.includes('localhost'); + if (!databaseIsLocal) return false; + if (input.requireE2EUser === true && !input.e2eUserId) return false; + if (input.requireStubOpenAI === true) { + const isStubOpenAI = + input.openAiApiKey === 'sk-test' || input.openAiApiKey?.startsWith('sk-e2e-stub-') === true; + if (!isStubOpenAI) return false; + } + return true; +} + // Override Cloudflare binding types with proper TypeScript types export type ValidatedEnv = Omit< ValidatedAppEnv, diff --git a/packages/api/src/utils/getPresignedUrl.ts b/packages/api/src/utils/getPresignedUrl.ts index aacc13416b..14a6d6c2a3 100644 --- a/packages/api/src/utils/getPresignedUrl.ts +++ b/packages/api/src/utils/getPresignedUrl.ts @@ -1,13 +1,23 @@ -import { type GetObjectCommand, type PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { + GetObjectCommand, + PutObjectCommand, + S3Client as S3ClientType, +} from '@aws-sdk/client-s3'; +import type { getSignedUrl as getSignedUrlType } from '@aws-sdk/s3-request-presigner'; import { getEnv } from './env-validation'; +type GetSignedUrlOptions = Parameters[2]; + export async function getPresignedUrl(opts: { command: GetObjectCommand | PutObjectCommand; - signOptions: Parameters[2]; + signOptions: GetSignedUrlOptions; }): Promise { const { command, signOptions } = opts; const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, CLOUDFLARE_ACCOUNT_ID } = getEnv(); + const [{ S3Client }, { getSignedUrl }] = (await Promise.all([ + import('@aws-sdk/client-s3'), + import('@aws-sdk/s3-request-presigner'), + ])) as [{ S3Client: typeof S3ClientType }, { getSignedUrl: typeof getSignedUrlType }]; const s3Client = new S3Client({ region: 'auto', diff --git a/packages/api/src/utils/json-utils.ts b/packages/api/src/utils/json-utils.ts index ce17e4b6d5..67f125db72 100644 --- a/packages/api/src/utils/json-utils.ts +++ b/packages/api/src/utils/json-utils.ts @@ -1,5 +1,5 @@ import { parseCatalogJson, parseFaqs, parsePrice, parseWeight } from '@packrat/api/utils/csv-utils'; -import type { NewCatalogItem } from '@packrat/db'; +import type { NewCatalogItem } from '@packrat/db/schema'; import { isNumber, isObject, isString, toStringRecord } from '@packrat/guards'; import { AvailabilitySchema, WeightUnitSchema } from '@packrat/schemas/constants'; import { safeJsonParse } from '@packrat/utils'; diff --git a/packages/api/src/workflows/catalog-etl-workflow.ts b/packages/api/src/workflows/catalog-etl-workflow.ts index 9cffef8ddc..3d6ba8a7fb 100644 --- a/packages/api/src/workflows/catalog-etl-workflow.ts +++ b/packages/api/src/workflows/catalog-etl-workflow.ts @@ -34,7 +34,7 @@ import { queryMetricsAls, } from '@packrat/api/utils/queryMetrics'; import { record } from '@packrat/api/utils/sentry'; -import { etlJobs, type NewCatalogItem, type NewInvalidItemLog } from '@packrat/db'; +import { etlJobs, type NewCatalogItem, type NewInvalidItemLog } from '@packrat/db/schema'; import { toRecord } from '@packrat/guards'; import { safeJsonParse } from '@packrat/utils'; import { parse } from 'csv-parse'; diff --git a/packages/api/test/etl.test.ts b/packages/api/test/etl.test.ts index 6cb5bb639c..2bf33b4476 100644 --- a/packages/api/test/etl.test.ts +++ b/packages/api/test/etl.test.ts @@ -3,7 +3,7 @@ import { CatalogService } from '@packrat/api/services'; import { processCatalogETL } from '@packrat/api/services/etl/processCatalogEtl'; import { processValidItemsBatch } from '@packrat/api/services/etl/processValidItemsBatch'; import { R2BucketService } from '@packrat/api/services/r2-bucket'; -import { catalogItems, etlJobs, invalidItemLogs } from '@packrat/db'; +import { catalogItems, etlJobs, invalidItemLogs } from '@packrat/db/schema'; import { count, eq } from 'drizzle-orm'; import { describe, expect, it, vi } from 'vitest'; diff --git a/packages/api/test/feature-access.test.ts b/packages/api/test/feature-access.test.ts index 54f5659965..489225022d 100644 --- a/packages/api/test/feature-access.test.ts +++ b/packages/api/test/feature-access.test.ts @@ -1,6 +1,6 @@ import { createDbClient } from '@packrat/api/db'; import { canAccessFeature } from '@packrat/api/services'; -import { featureAccess } from '@packrat/db'; +import { featureAccess } from '@packrat/db/schema'; import { describe, expect, it } from 'vitest'; import { app } from '../src/index'; diff --git a/packages/api/test/fixtures/catalog-fixtures.ts b/packages/api/test/fixtures/catalog-fixtures.ts index a7eb9c7c23..52394e7bb1 100644 --- a/packages/api/test/fixtures/catalog-fixtures.ts +++ b/packages/api/test/fixtures/catalog-fixtures.ts @@ -1,4 +1,4 @@ -import type { catalogItems } from '@packrat/db'; +import type { catalogItems } from '@packrat/db/schema'; import type { InferInsertModel } from 'drizzle-orm'; /** diff --git a/packages/api/test/fixtures/pack-fixtures.ts b/packages/api/test/fixtures/pack-fixtures.ts index ea1ae72c50..8905ace583 100644 --- a/packages/api/test/fixtures/pack-fixtures.ts +++ b/packages/api/test/fixtures/pack-fixtures.ts @@ -1,4 +1,4 @@ -import type { packItems, packs } from '@packrat/db'; +import type { packItems, packs } from '@packrat/db/schema'; import type { InferInsertModel } from 'drizzle-orm'; type PackOverrides = Partial> & { userId: string }; diff --git a/packages/api/test/fixtures/pack-template-fixtures.ts b/packages/api/test/fixtures/pack-template-fixtures.ts index 43c8c64eab..3eec9d6abf 100644 --- a/packages/api/test/fixtures/pack-template-fixtures.ts +++ b/packages/api/test/fixtures/pack-template-fixtures.ts @@ -1,4 +1,4 @@ -import type { packTemplateItems, packTemplates } from '@packrat/db'; +import type { packTemplateItems, packTemplates } from '@packrat/db/schema'; import type { InferInsertModel } from 'drizzle-orm'; type PackTemplateOverrides = Partial> & { userId: string }; diff --git a/packages/api/test/packs.test.ts b/packages/api/test/packs.test.ts index 3e50d2389f..f8c56b697c 100644 --- a/packages/api/test/packs.test.ts +++ b/packages/api/test/packs.test.ts @@ -1,7 +1,7 @@ import { createDb } from '@packrat/api/db'; import { PackService } from '@packrat/api/services/packService'; -import type { Pack } from '@packrat/db'; -import { packItems, packs } from '@packrat/db'; +import type { Pack } from '@packrat/db/schema'; +import { packItems, packs } from '@packrat/db/schema'; import { eq } from 'drizzle-orm'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { diff --git a/packages/api/test/utils/db-helpers.ts b/packages/api/test/utils/db-helpers.ts index b0154fff2b..7dfb22ccdc 100644 --- a/packages/api/test/utils/db-helpers.ts +++ b/packages/api/test/utils/db-helpers.ts @@ -1,5 +1,6 @@ import { createDb } from '@packrat/api/db'; import { hashPassword } from '@packrat/api/utils/auth'; +import * as schema from '@packrat/db/schema'; import { catalogItems, packItems, @@ -7,8 +8,7 @@ import { packTemplateItems, packTemplates, type users, -} from '@packrat/db'; -import * as schema from '@packrat/db/schema'; +} from '@packrat/db/schema'; import { assertDefined } from '@packrat/guards'; import type { InferInsertModel } from 'drizzle-orm'; import { createFatCatalogItem, createTestCatalogItem } from '../fixtures/catalog-fixtures'; diff --git a/packages/api/test/utils/user-helpers.ts b/packages/api/test/utils/user-helpers.ts index 55a4b9d163..551b01feba 100644 --- a/packages/api/test/utils/user-helpers.ts +++ b/packages/api/test/utils/user-helpers.ts @@ -1,6 +1,6 @@ import { createDb } from '@packrat/api/db'; import { hashPassword } from '@packrat/api/utils/auth'; -import { users } from '@packrat/db'; +import { users } from '@packrat/db/schema'; import type { InferInsertModel } from 'drizzle-orm'; /** diff --git a/packages/app/package.json b/packages/app/package.json index cce416df24..31ac4936ec 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/app", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/checks/package.json b/packages/checks/package.json index 87918a838d..ecfbddd65a 100644 --- a/packages/checks/package.json +++ b/packages/checks/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/checks", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 2bfbcd831b..de76deb1da 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/cli", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "bin": { diff --git a/packages/config/package.json b/packages/config/package.json index bb7079e1d3..2897b68b66 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/config", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/consent-ui/package.json b/packages/consent-ui/package.json index c47ed3b29f..0f640225e0 100644 --- a/packages/consent-ui/package.json +++ b/packages/consent-ui/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/consent-ui", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/constants/package.json b/packages/constants/package.json index cc00c8aa9b..1d27a623f8 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/constants", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/db/package.json b/packages/db/package.json index e516f3d081..2aa3713419 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/db", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/env/package.json b/packages/env/package.json index 1ca6bd7948..762d23419e 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/env", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/env/src/node.ts b/packages/env/src/node.ts index e67148e786..5487135f5c 100644 --- a/packages/env/src/node.ts +++ b/packages/env/src/node.ts @@ -84,9 +84,12 @@ export const nodeEnvSchema = z.object({ E2E_PASSWORD: z.string().min(1).optional(), E2E_TEST_EMAIL: z.string().email().optional(), E2E_TEST_PASSWORD: z.string().min(1).optional(), + E2E_API_BASE_URL: z.string().url().optional(), // ── OpenAI (packages/api/src/db/seed-e2e-catalog.ts) ────────────── OPENAI_API_KEY: z.string().min(1).optional(), + WEATHER_API_KEY: z.string().min(1).optional(), + APPLE_PRIVATE_KEY: z.string().min(1).optional(), E2E_API_URL: z.string().url().optional(), E2E_DB_URL: z.string().url().optional(), E2E_DB_PORT: z.string().regex(/^\d+$/, 'E2E_DB_PORT must be a numeric string').optional(), @@ -115,10 +118,20 @@ export const nodeEnvSchema = z.object({ PACKRAT_WATCH_SYNC_WAIT_MS: z.string().regex(/^\d+$/).optional(), PACKRAT_WATCH_SYNC_PHONE_ID: z.string().min(1).optional(), PACKRAT_WATCH_SYNC_WATCH_ID: z.string().min(1).optional(), + APPLE_ID: z.string().email().optional(), + APPLE_APP_PASSWORD: z.string().min(1).optional(), + APPLE_TEAM_ID: z.string().min(1).optional(), + APPLE_ASC_PROVIDER: z.string().min(1).optional(), + BUILD_NUMBER: z.string().regex(/^\d+$/).optional(), + APP_STORE_CURRENT_BUILD_NUMBER: z.string().regex(/^\d+$/).optional(), }); export type NodeEnv = z.infer; +function optionalEnv(value: string | undefined): string | undefined { + return value?.trim() ? value : undefined; +} + /** * Typed env parsed from `process.env` at module load. Throws a Zod * validation error if any value fails its schema constraint. @@ -158,7 +171,10 @@ export const nodeEnv = nodeEnvSchema.parse({ E2E_PASSWORD: process.env.E2E_PASSWORD, E2E_TEST_EMAIL: process.env.E2E_TEST_EMAIL, E2E_TEST_PASSWORD: process.env.E2E_TEST_PASSWORD, + E2E_API_BASE_URL: optionalEnv(process.env.E2E_API_BASE_URL), OPENAI_API_KEY: process.env.OPENAI_API_KEY, + WEATHER_API_KEY: process.env.WEATHER_API_KEY, + APPLE_PRIVATE_KEY: process.env.APPLE_PRIVATE_KEY, E2E_API_URL: process.env.E2E_API_URL, E2E_DB_URL: process.env.E2E_DB_URL, E2E_DB_PORT: process.env.E2E_DB_PORT, @@ -187,4 +203,10 @@ export const nodeEnv = nodeEnvSchema.parse({ PACKRAT_WATCH_SYNC_WAIT_MS: process.env.PACKRAT_WATCH_SYNC_WAIT_MS, PACKRAT_WATCH_SYNC_PHONE_ID: process.env.PACKRAT_WATCH_SYNC_PHONE_ID, PACKRAT_WATCH_SYNC_WATCH_ID: process.env.PACKRAT_WATCH_SYNC_WATCH_ID, + APPLE_ID: process.env.APPLE_ID, + APPLE_APP_PASSWORD: process.env.APPLE_APP_PASSWORD, + APPLE_TEAM_ID: process.env.APPLE_TEAM_ID, + APPLE_ASC_PROVIDER: process.env.APPLE_ASC_PROVIDER, + BUILD_NUMBER: process.env.BUILD_NUMBER, + APP_STORE_CURRENT_BUILD_NUMBER: process.env.APP_STORE_CURRENT_BUILD_NUMBER, }); diff --git a/packages/guards/package.json b/packages/guards/package.json index bbd31f0e35..8010e105c2 100644 --- a/packages/guards/package.json +++ b/packages/guards/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/guards", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 22bf3a7839..81ebe77e89 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/mcp", - "version": "2.1.0", + "version": "2.2.0", "private": true, "description": "PackRat MCP Server — outdoor adventure planning via Model Context Protocol", "scripts": { diff --git a/packages/mcp/src/constants.ts b/packages/mcp/src/constants.ts index 7b792909b1..f4f2e7fe64 100644 --- a/packages/mcp/src/constants.ts +++ b/packages/mcp/src/constants.ts @@ -21,6 +21,6 @@ export const ServiceMeta = { Name: 'packrat-mcp', /** MCP-server display name surfaced to clients. */ McpServerName: 'packrat', - Version: '2.1.0', + Version: '2.2.0', Transport: 'streamable-http', } as const; diff --git a/packages/osm-db/package.json b/packages/osm-db/package.json index ed76921925..a557f90c92 100644 --- a/packages/osm-db/package.json +++ b/packages/osm-db/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/osm-db", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/osm-import/package.json b/packages/osm-import/package.json index b3c55f6c39..3f42fe5422 100644 --- a/packages/osm-import/package.json +++ b/packages/osm-import/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/osm-import", - "version": "2.1.0", + "version": "2.2.0", "private": true, "description": "osm2pgsql flex config and import tooling for PackRat outdoor routes", "type": "module", diff --git a/packages/overpass/package.json b/packages/overpass/package.json index 2ce31cd64a..078bf10498 100644 --- a/packages/overpass/package.json +++ b/packages/overpass/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/overpass", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 42a6ec210d..a04b8df94b 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/schemas", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/types/package.json b/packages/types/package.json index 768ae63615..b40eae0277 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/types", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index e9d99ff59f..1ecbbb66b7 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/typescript-config", - "version": "2.1.0", + "version": "2.2.0", "private": true, "files": [ "base.json", diff --git a/packages/ui/package.json b/packages/ui/package.json index f849ef4a5a..c16b6a56af 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/ui", - "version": "2.1.0", + "version": "2.2.0", "private": true, "scripts": { "check-types": "tsc --noEmit" diff --git a/packages/units/package.json b/packages/units/package.json index f72e24af0d..db91088657 100644 --- a/packages/units/package.json +++ b/packages/units/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/units", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/utils/package.json b/packages/utils/package.json index eebf42091d..f43e0cc197 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/utils", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index d59afe8453..68b772422c 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/web-ui", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "exports": {