Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
157e881
Merge remote-tracking branch 'origin/main' into codex/swift-full-e2e-…
andrew-bierman May 25, 2026
d0a6b1d
Merge remote-tracking branch 'origin/development' into codex/swift-fu…
andrew-bierman May 25, 2026
55b662d
🧪 test(swift): add visual screenshot capture
andrew-bierman May 25, 2026
09683a1
🙈 chore: ignore generated artifacts
andrew-bierman May 25, 2026
4153502
🧪 test(web): add visual screenshot matrix
andrew-bierman May 25, 2026
f0d1c0d
🧪 test(swift): capture authenticated visual states
andrew-bierman May 25, 2026
81932fe
🐛 fix(web): provide bottom sheet context
andrew-bierman May 25, 2026
925a7de
♿️ test(swift): polish native flows
andrew-bierman May 26, 2026
55f96d4
🧪 test(api): support local native e2e auth
andrew-bierman May 26, 2026
469c65d
♿️ fix(swift): separate guest and offline states
andrew-bierman May 26, 2026
a6d6b18
🙈 chore(git): ignore local env backups
andrew-bierman May 26, 2026
adcf480
🧪 test(swift): seed local authenticated e2e sessions
andrew-bierman May 26, 2026
471b611
💄 fix(swift): trim form copy and e2e locators
andrew-bierman May 26, 2026
cf6591f
🧪 fix(swift): align login e2e credentials
andrew-bierman May 26, 2026
30d7817
🧪 test(swift): add real-data visual screenshots
andrew-bierman May 26, 2026
3034916
🧪 test: validate AI streaming and API integration
andrew-bierman May 26, 2026
15a7279
🧪 test(swift): require visual e2e coverage matrix
andrew-bierman May 26, 2026
ab2f8d0
🧪 test(swift): fail visual captures on error states
andrew-bierman May 26, 2026
89ded3c
💄 style(swift): center reusable unavailable states
andrew-bierman May 26, 2026
7c35aff
💄 style(swift): rebuild global search with native UI
andrew-bierman May 26, 2026
0c32bbf
🔐 test(swift): split guest limits from auth failures
andrew-bierman May 26, 2026
d8706c7
🖼️ test(swift): split visual contact sheets by state
andrew-bierman May 26, 2026
5737089
💄 style(swift): polish guest account states
andrew-bierman May 26, 2026
0509b76
🧪 test(swift): seed guide visual coverage
andrew-bierman May 27, 2026
efb78fe
🖼️ test(swift): expand visual state coverage
andrew-bierman May 27, 2026
288fa16
🖼️ test(swift): clarify guest visual states
andrew-bierman May 27, 2026
2efd866
💄 style(swift): widen mac form sheets
andrew-bierman May 28, 2026
9305a83
💄 style(swift): standardize secondary sheets
andrew-bierman May 28, 2026
fcb36cd
🖼️ test(swift): cover offline and ai visual states
andrew-bierman May 28, 2026
ca311cd
🖼️ test(swift): stabilize visual modal coverage
andrew-bierman May 28, 2026
ab268f3
💄 style(swift): tighten auth surfaces
andrew-bierman May 28, 2026
ed12d09
🖼️ test(swift): expand native form screenshot coverage
andrew-bierman May 28, 2026
719925f
🍎 Use native SwiftUI search and filters
andrew-bierman May 28, 2026
6c36c8f
🧰 Harden Swift visual screenshot runner
andrew-bierman May 28, 2026
81e161a
💄 Polish native Swift form sheets
andrew-bierman May 28, 2026
52a19c6
🖼️ Add nightly Swift visual screenshots
andrew-bierman May 29, 2026
ef120d1
📝 Document Swift visual testing catalog
andrew-bierman May 29, 2026
9fc9c5a
Merge remote-tracking branch 'origin/development' into codex/swift-fu…
andrew-bierman May 29, 2026
c99b35b
⌚️ Add iPad family and watch companion shell
andrew-bierman May 29, 2026
f6041f6
🖼️ Add iPad visual screenshot coverage
andrew-bierman May 29, 2026
cf10a3c
⌚️ Build out watch companion sync
andrew-bierman May 29, 2026
c139485
🧭 Keep watch fallback honest
andrew-bierman May 29, 2026
211eae9
🧪 Stabilize iOS visual simulator selection
andrew-bierman May 29, 2026
795dd80
🧪 Split Mac visual screenshot sweeps
andrew-bierman May 29, 2026
ef91fe0
⌚️ Capture synced watch states
andrew-bierman May 29, 2026
fcc0082
⌚️ Verify watch companion sync
andrew-bierman May 30, 2026
53534dc
🧪 Harden Swift E2E fixtures
andrew-bierman May 30, 2026
91ae4d2
💾 Add local-first CRUD fallbacks
andrew-bierman May 30, 2026
3abd77e
🐛 Stabilize SwiftUI E2E flows
andrew-bierman May 30, 2026
a8d042a
🧪 Require full Swift visual catalog states
andrew-bierman May 30, 2026
819227b
🎨 Add Swift app store icons
andrew-bierman May 30, 2026
8fc7724
🚩 Align Swift feature flags
andrew-bierman May 31, 2026
21afd75
🏗️ Generate Swift app config
andrew-bierman May 31, 2026
d0bb8d2
Merge remote-tracking branch 'origin/development' into codex/swift-fu…
andrew-bierman Jun 1, 2026
b04a459
✅ Fix embedding helper test typing
andrew-bierman Jun 1, 2026
22d1dda
🧪 Harden Swift visual catalog and local-first stores
andrew-bierman Jun 1, 2026
7b6bac5
✅ Use guard helpers in API validation
andrew-bierman Jun 1, 2026
43a7184
🧹 Align screenshot scripts with clean checks
andrew-bierman Jun 1, 2026
be6b077
🧹 Migrate Swift scripts to env shim
andrew-bierman Jun 1, 2026
f3e2b48
🧹 Sort package scripts
andrew-bierman Jun 1, 2026
15b927b
✅ Validate Swift screenshot JSON
andrew-bierman Jun 1, 2026
3ebea02
🔀 Sync Swift validation branch with development
andrew-bierman Jun 1, 2026
40f0f66
✅ Harden coverage after development sync
andrew-bierman Jun 1, 2026
e8d0690
🔀 Sync Swift validation branch with development
andrew-bierman Jun 3, 2026
bbc4f7f
♿️ Harden Swift navigation state identifiers
andrew-bierman Jun 3, 2026
d2d20c5
🔀 Sync Swift validation branch with development
andrew-bierman Jun 11, 2026
eae7f82
🔀 Sync Swift validation branch with development
andrew-bierman Jun 16, 2026
e56595a
✅ Stabilize catalog Maestro assertions
andrew-bierman Jun 16, 2026
fcce112
🔀 Sync Swift catalog validation with development
andrew-bierman Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions .github/workflows/swift-visual.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: Swift Visual Screenshots

on:
schedule:
- cron: "21 10 * * *"
workflow_dispatch:
inputs:
platform:
description: "Which visual suite to run"
required: true
default: both
type: choice
options:
- both
- ios
- ipad
- macos

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read

env:
SCREENSHOT_ARTIFACT_DIR: artifacts/swift-visual-screenshots

jobs:
visual-gate:
name: Check visual test prerequisites
runs-on: ubuntu-latest
outputs:
ready: ${{ steps.check.outputs.ready }}
steps:
- id: check
name: Verify E2E credentials are available
env:
E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }}
E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}
run: |
if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$E2E_TEST_PASSWORD" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::notice::Swift visual screenshots skipped because E2E_TEST_EMAIL/E2E_TEST_PASSWORD are not configured."
fi

swift-visual:
name: Swift visual suite
needs: visual-gate
if: needs.visual-gate.outputs.ready == 'true'
runs-on: macos-15
timeout-minutes: 90

steps:
- uses: actions/checkout@v6

- name: Select Xcode
run: |
sudo xcode-select -switch /Applications/Xcode.app
xcodebuild -version
xcrun --show-sdk-version

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install xcodegen
run: brew install xcodegen

- name: Install dependencies
env:
PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }}
run: bun install --frozen-lockfile

- name: Generate Xcode project
run: bun swift

- name: Boot iOS simulator
if: ${{ (github.event.inputs.platform || 'both') != 'macos' }}
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)")
xcrun simctl boot "$DEVICE_ID"
xcrun simctl bootstatus "$DEVICE_ID"

- name: Boot iPad simulator
if: ${{ (github.event.inputs.platform || 'both') == 'ipad' || (github.event.inputs.platform || 'both') == 'both' }}
run: |
DEVICE_ID=$(xcrun simctl list devices available -j \
| jq -r '[.devices[][] | select(.isAvailable == true and (.name | contains("iPad")))] | .[0].udid')
xcrun simctl boot "$DEVICE_ID" || true
xcrun simctl bootstatus "$DEVICE_ID"

- name: Check macOS Automation Mode
if: ${{ (github.event.inputs.platform || 'both') == 'macos' || (github.event.inputs.platform || 'both') == 'both' }}
run: automationmodetool help

- name: Capture Swift visual screenshots
env:
E2E_EMAIL: ${{ secrets.E2E_TEST_EMAIL }}
E2E_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}
E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }}
E2E_TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }}
PACKRAT_VISUAL_XCODEBUILD_TIMEOUT_MS: "3600000"
run: |
PLATFORM="${{ github.event.inputs.platform || 'both' }}"
bun swift:screenshots --platform "$PLATFORM" --out "$SCREENSHOT_ARTIFACT_DIR"

- name: Upload contact sheets
if: always()
uses: actions/upload-artifact@v4
with:
name: swift-visual-screenshots
path: |
${{ env.SCREENSHOT_ARTIFACT_DIR }}
apps/swift/TestResults/visual-*.xcresult
if-no-files-found: ignore
retention-days: 14
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ node_modules
# output
out
dist
artifacts/
*.tgz

# code coverage
Expand All @@ -21,6 +22,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.env.test.local
.env.production.local
.env.local
.env*.bak.*
.envrc

# caches
Expand All @@ -41,6 +43,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.claude/worktrees/
.claude/scheduled_tasks.lock
.dev.vars
.dev.vars.e2e
.dev.vars*.bak.*

# Generated OG images (produced at build time by scripts/generate-og-images.ts)
apps/landing/public/og-image.png
Expand All @@ -58,7 +62,7 @@ apps/guides/public/og/
apps/swift/xcconfig/*.local.xcconfig
apps/swift/PackRat.xcodeproj/
apps/swift/*.xcworkspace/xcuserdata/
apps/swift/DerivedData/
apps/swift/DerivedData*/
apps/swift/.build/
apps/swift/.swiftpm/
apps/swift/Package.resolved
Expand Down
4 changes: 2 additions & 2 deletions .maestro/flows/catalog/catalog-browse-flow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ appId: ${APP_ID}
text: "All"
commands:
- assertVisible:
text: ".*items.*"
id: "catalog:item-.*"
- scrollUntilVisible:
element:
text: "Showing.*items"
id: "catalog:item-.*"
direction: DOWN
timeout: 10000
speed: 20
Expand Down
4 changes: 2 additions & 2 deletions .maestro/flows/catalog/catalog-search-flow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ appId: ${APP_ID}
- assertVisible:
text: "All"
- assertVisible:
text: ".*items.*"
id: "catalog:item-.*"
- tapOn:
text: "Clothing"
- waitForAnimationToEnd
Expand All @@ -43,4 +43,4 @@ appId: ${APP_ID}
text: "All"
- waitForAnimationToEnd
- assertVisible:
text: ".*items.*"
id: "catalog:item-.*"
12 changes: 12 additions & 0 deletions apps/expo/lib/utils/__tests__/getRelativeTime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ describe('getRelativeTime', () => {
expect(t).toHaveBeenCalledWith('common.timeAgo.justNow');
});

it('returns "Just now" for null timestamps without translation', () => {
expect(getRelativeTime({ dateValue: null })).toBe('Just now');
});

it('calls translate with singular unit counts', () => {
vi.setSystemTime(new Date('2024-01-01T12:01:00Z'));
const t = vi.fn((key: string, opts?: Record<string, unknown>) => `${key}:${opts?.count}`);
const result = getRelativeTime({ dateValue: '2024-01-01T12:00:00Z', t: t as never });
expect(t).toHaveBeenCalledWith('common.timeAgo.minutes', { count: 1 });
expect(result).toBe('common.timeAgo.minutes:1');
});

it('accepts a Date object input', () => {
vi.setSystemTime(new Date('2024-01-01T12:05:00Z'));
const result = getRelativeTime({ dateValue: new Date('2024-01-01T12:00:00Z') });
Expand Down
1 change: 1 addition & 0 deletions apps/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"format": "biome format --write",
"ios": "APP_VARIANT=development expo run:ios",
"lint": "biome check --write",
"screenshots:web": "bun run playwright/capture-web-screenshots.ts",
"start": "APP_VARIANT=development EXPO_UNSTABLE_WEB_MODAL=1 expo start",
"submit:android": "eas submit --platform android",
"submit:ios": "eas submit --platform ios",
Expand Down
118 changes: 118 additions & 0 deletions apps/expo/playwright/capture-web-screenshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env bun
import { spawnSync } from 'node:child_process';
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { chromium } from '@playwright/test';

const REPO_ROOT = resolve(import.meta.dir, '../../..');
const EXPO_DIR = resolve(REPO_ROOT, 'apps/expo');
const OUT_DIR = resolve(REPO_ROOT, 'artifacts/screenshots');
const WEB_DIR = resolve(OUT_DIR, 'web-playwright');
const CONTACT_SHEET_HTML = resolve(OUT_DIR, 'web-contact-sheet.html');
const CONTACT_SHEET_PNG = resolve(OUT_DIR, 'web-contact-sheet.png');

rmSync(WEB_DIR, { recursive: true, force: true });
mkdirSync(WEB_DIR, { recursive: true });

const result = spawnSync(
'bunx',
[
'playwright',
'test',
'--config',
'playwright/playwright.visual.config.ts',
'--grep',
'Web visual screenshot matrix',
],
{
cwd: EXPO_DIR,
env: {
...process.env,
PACKRAT_VISUAL_SCREENSHOTS: '1',
},
stdio: 'inherit',
},
);

await renderContactSheet();

if (result.status !== 0) {
process.exit(result.status ?? 1);
}

async function renderContactSheet() {
const screenshots = readdirSync(WEB_DIR)
.filter((file) => file.endsWith('.png'))
.sort()
.map((file) => resolve(WEB_DIR, file));
if (screenshots.length === 0) return;

const cards = screenshots
.map((file) => {
const src = pathToFileURL(file).href;
const label = stripSortPrefix(basename(file, '.png'))
.replaceAll('-', ' ')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return `<figure><img src="${src}" /><figcaption>${escapeHtml(label)}</figcaption></figure>`;
})
.join('\n');

const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { margin: 0; padding: 28px; background: #f5f5f7; color: #1d1d1f; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; }
h1 { margin: 0 0 22px; font-size: 22px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; align-items: start; }
figure { margin: 0; padding: 10px; background: white; border: 1px solid #e5e5ea; border-radius: 10px; box-shadow: 0 1px 2px rgb(0 0 0 / 0.05); }
img { display: block; width: 100%; border: 1px solid #eee; border-radius: 6px; }
figcaption { padding-top: 8px; color: #6e6e73; font-size: 12px; }
</style>
</head>
<body>
<h1>PackRat Web Screens</h1>
<main class="grid">${cards}</main>
</body>
</html>`;

writeFileSync(CONTACT_SHEET_HTML, html);
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
viewport: { width: 1800, height: estimateHeight(screenshots) },
});
await page.goto(pathToFileURL(CONTACT_SHEET_HTML).href);
await page.screenshot({ path: CONTACT_SHEET_PNG, fullPage: true });
await browser.close();

const bytes = readFileSync(CONTACT_SHEET_PNG).byteLength;
console.log(`✓ Wrote ${CONTACT_SHEET_PNG} (${Math.round(bytes / 1024)} KB)`);
}

function estimateHeight(screenshots: string[]): number {
return Math.max(1200, Math.ceil(screenshots.length / 3) * 700 + 120);
}

function escapeHtml(value: string): string {
return Array.from(value, (char) => {
if (char === '&') return '&amp;';
if (char === '<') return '&lt;';
if (char === '>') return '&gt;';
if (char === '"') return '&quot;';
if (char === "'") return '&#39;';
return char;
}).join('');
}

function stripSortPrefix(value: string): string {
let index = 0;
while (index < value.length) {
const code = value.charCodeAt(index);
if (code < 48 || code > 57) break;
index += 1;
}
return value.charAt(index) === '-' ? value.slice(index + 1) : value;
}
22 changes: 22 additions & 0 deletions apps/expo/playwright/playwright.visual.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineConfig, devices } from '@playwright/test';

const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8081';

export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: [['list']],
use: {
baseURL: BASE_URL,
headless: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
Loading
Loading