diff --git a/.agents/skills/mobile-e2e/SKILL.md b/.agents/skills/mobile-e2e/SKILL.md index 106eb599a..3188d7af2 100644 --- a/.agents/skills/mobile-e2e/SKILL.md +++ b/.agents/skills/mobile-e2e/SKILL.md @@ -70,6 +70,18 @@ xcodebuild -workspace Folo.xcworkspace \ build ``` +### Apple Silicon simulator optimization + +When running on an Apple Silicon Mac and building only for the simulator used in the current run, prefer compiling only the active `arm64` simulator architecture: + +```bash +xcodebuild ... \ + ONLY_ACTIVE_ARCH=YES \ + ARCHS=arm64 +``` + +Use this optimization only for local self-test / e2e simulator builds tied to the current machine. Do not use it when you need a universal simulator app for other machines or when running on Intel Macs. + Expected output pattern: ```bash diff --git a/.agents/skills/mobile-self-test/SKILL.md b/.agents/skills/mobile-self-test/SKILL.md index 91ac5634d..67054e2c9 100644 --- a/.agents/skills/mobile-self-test/SKILL.md +++ b/.agents/skills/mobile-self-test/SKILL.md @@ -163,6 +163,15 @@ xcodebuild -workspace Folo.xcworkspace \ clean build ``` +On Apple Silicon Macs, when the build is only for the dedicated simulator created for the current self-test run, prefer compiling only the active `arm64` simulator architecture: + +```bash +ONLY_ACTIVE_ARCH=YES \ +ARCHS=arm64 +``` + +Do not use that optimization when you need a universal simulator bundle for other machines or when the host Mac is Intel. + Expected output pattern: ```bash @@ -299,6 +308,15 @@ export E2E_PASSWORD='Password123!' export E2E_EMAIL="folo-self-test-$(date +%Y%m%d%H%M%S)@example.com" ``` +For non-auth iOS self-tests, bootstrap auth through the standard iOS runner mode after the app has been installed and launched once: + +```bash +cd apps/mobile +pnpm run e2e:ios:bootstrap +``` + +This bootstrap path is the default for `prod` and `local` self-tests. Only skip it when the feature under test is login, registration, sign-out, session restoration, or another auth-specific flow that must be validated visually end-to-end. + #### iOS registration bootstrap ```bash diff --git a/apps/mobile/e2e/README.md b/apps/mobile/e2e/README.md index be0ca1658..0fa68724a 100644 --- a/apps/mobile/e2e/README.md +++ b/apps/mobile/e2e/README.md @@ -13,6 +13,7 @@ pnpm run e2e:doctor pnpm run e2e:android pnpm run e2e:ios +pnpm run e2e:ios:bootstrap ``` ## iOS Notes @@ -22,6 +23,27 @@ pnpm run e2e:ios - `content.yaml`: ensure onboarding feed unfollowed -> follow -> timeline/read-unread -> unfollow - The iOS runner resets the simulator, disables password autofill prompts, installs the provided app bundle, then executes Maestro. +## Prod iOS auth bootstrap + +When non-auth iOS self-tests need a signed-in simulator quickly, bootstrap auth through the standard iOS runner mode: + +```bash +pnpm run e2e:ios:bootstrap +``` + +This mode uses the auth bootstrap helper on iOS when `EXPO_PUBLIC_E2E_ENV_PROFILE=prod` or `EXPO_PUBLIC_E2E_ENV_PROFILE=local`, and falls back to the normal iOS registration flow for other environments. + +Optional environment variables: + +- `E2E_EMAIL` +- `E2E_PASSWORD` +- `MAESTRO_IOS_DEVICE_ID` +- `E2E_API_URL` +- `E2E_CALLBACK_URL` +- `E2E_BUNDLE_ID` + +The bootstrap script signs in against prod using the mobile fallback token header, writes the auth cookie into the simulator's `ExpoSQLiteStorage` fallback store, and relaunches the app. + ## Environment - `E2E_EMAIL` diff --git a/apps/mobile/e2e/run-maestro.sh b/apps/mobile/e2e/run-maestro.sh index 1cee57d13..aafdb9335 100755 --- a/apps/mobile/e2e/run-maestro.sh +++ b/apps/mobile/e2e/run-maestro.sh @@ -2,6 +2,7 @@ set -eu platform="${1:?platform is required}" +mode="${2:-full}" debug_output="${MAESTRO_DEBUG_OUTPUT:-e2e/artifacts/${platform}}" run_suffix="$(date +%s)-$$" @@ -40,6 +41,7 @@ extract_ios_app_from_tar() { } resolve_ios_app_path() { + device_id="$1" if [ -n "${MAESTRO_IOS_APP_PATH:-}" ]; then if [ -d "${MAESTRO_IOS_APP_PATH}" ]; then printf '%s' "${MAESTRO_IOS_APP_PATH}" @@ -60,7 +62,13 @@ resolve_ios_app_path() { return fi - find "$HOME/Library/Developer/Xcode/DerivedData" -path '*Build/Products/Release-iphonesimulator/Folo.app' | head -n1 + existing_path="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path '*Build/Products/Release-iphonesimulator/Folo.app' | head -n1)" + if [ -n "${existing_path}" ]; then + printf '%s' "${existing_path}" + return + fi + + build_ios_simulator_app "${device_id}" } wait_for_android_ready() { @@ -106,6 +114,48 @@ prepare_ios_simulator() { xcrun simctl bootstatus "${device_id}" -b >/dev/null 2>&1 || true } +append_ios_arch_args() { + arch="$(uname -m)" + if [ "${arch}" = "arm64" ]; then + printf '%s\n' "ONLY_ACTIVE_ARCH=YES" "ARCHS=arm64" + fi +} + +build_ios_simulator_app() { + device_id="$1" + ( + cd ios + pod install + set -- $(append_ios_arch_args) + PROFILE=e2e-ios-simulator \ + EXPO_PUBLIC_E2E_ENV_PROFILE="${EXPO_PUBLIC_E2E_ENV_PROFILE:-}" \ + EXPO_PUBLIC_E2E_LANGUAGE="${EXPO_PUBLIC_E2E_LANGUAGE:-en}" \ + xcodebuild -workspace Folo.xcworkspace \ + -scheme Folo \ + -configuration Release \ + -sdk iphonesimulator \ + -destination "id=${device_id}" \ + build \ + "$@" + ) + + find "$HOME/Library/Developer/Xcode/DerivedData" -path '*Build/Products/Release-iphonesimulator/Folo.app' | head -n1 +} + +run_ios_bootstrap_auth() { + device_id="$1" + + case "${EXPO_PUBLIC_E2E_ENV_PROFILE:-prod}" in + prod|local) + pnpm run e2e:bootstrap:ios:prod-auth -- --udid "${device_id}" + ;; + *) + maestro test --format junit --platform ios --device "${device_id}" --debug-output "${debug_output}/bootstrap-auth" \ + -e E2E_EMAIL="${E2E_EMAIL}" -e E2E_PASSWORD="${E2E_PASSWORD}" e2e/flows/ios/register.yaml + ;; + esac +} + case "${platform}" in ios) device_id="$(resolve_ios_device)" @@ -114,7 +164,7 @@ case "${platform}" in exit 1 fi - app_path="$(resolve_ios_app_path)" + app_path="$(resolve_ios_app_path "${device_id}")" if [ -z "${app_path}" ] || [ ! -d "${app_path}" ]; then echo "Unable to resolve a built iOS .app bundle. Set MAESTRO_IOS_APP_PATH or place a build-*.tar.gz in apps/mobile." >&2 exit 1 @@ -124,8 +174,19 @@ case "${platform}" in xcrun simctl install "${device_id}" "${app_path}" >/dev/null 2>&1 || true xcrun simctl launch "${device_id}" is.follow >/dev/null 2>&1 || true - maestro test --format junit --platform ios --device "${device_id}" --debug-output "${debug_output}/auth" \ - -e E2E_EMAIL="${E2E_EMAIL}" -e E2E_PASSWORD="${E2E_PASSWORD}" e2e/flows/ios/auth.yaml + case "${mode}" in + full) + maestro test --format junit --platform ios --device "${device_id}" --debug-output "${debug_output}/auth" \ + -e E2E_EMAIL="${E2E_EMAIL}" -e E2E_PASSWORD="${E2E_PASSWORD}" e2e/flows/ios/auth.yaml + ;; + bootstrap-auth) + run_ios_bootstrap_auth "${device_id}" + ;; + *) + echo "Unsupported iOS runner mode: ${mode}" >&2 + exit 1 + ;; + esac ;; android) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c334ee53f..de122b7aa 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -8,8 +8,11 @@ "bump": "vv", "dev": "npm run start", "e2e:android": "sh ./e2e/run-maestro.sh android", + "e2e:bootstrap:ios:auth": "tsx scripts/e2e-prod-ios-auth-bootstrap.ts", + "e2e:bootstrap:ios:prod-auth": "pnpm run e2e:bootstrap:ios:auth", "e2e:doctor": "sh -c 'for f in e2e/flows/shared/*.yaml e2e/flows/android/*.yaml e2e/flows/ios/*.yaml; do maestro check-syntax $f; done'", "e2e:ios": "sh ./e2e/run-maestro.sh ios", + "e2e:ios:bootstrap": "sh ./e2e/run-maestro.sh ios bootstrap-auth", "eas-build-post-install": "rm -rf $TMPDIR/metro-cache", "eas-build-pre-install": "command -v pod >/dev/null 2>&1 && pod repo update || echo 'CocoaPods not found, skipping pod repo update'", "ios": "expo run:ios", diff --git a/apps/mobile/scripts/e2e-prod-ios-auth-bootstrap.ts b/apps/mobile/scripts/e2e-prod-ios-auth-bootstrap.ts new file mode 100644 index 000000000..18ad78d33 --- /dev/null +++ b/apps/mobile/scripts/e2e-prod-ios-auth-bootstrap.ts @@ -0,0 +1,389 @@ +import { execFileSync } from "node:child_process" +import { existsSync } from "node:fs" +import process from "node:process" + +import { join } from "pathe" + +type AuthError = { + code?: number + message?: string + status?: number + statusText?: string +} + +type AuthResponse = { + data: { + token?: string | null + } | null + error: AuthError | null +} + +type CookieValue = { + expires: string | null + value: string +} + +type CookieMap = Record + +const DEFAULT_BUNDLE_ID = "is.follow" +const DEFAULT_PASSWORD = "Password123!" +const COOKIE_STORAGE_KEY = "follow_secure_store_fallback:follow_auth_cookie" +const SESSION_TOKEN_STORAGE_KEY = "follow_secure_store_fallback:__Secure-better-auth.session_token" +const SESSION_COOKIE_KEYS = [ + "__Secure-better-auth.session_token", + "better-auth.session_token", +] as const +const envProfileDefaults = { + local: { + apiUrl: "http://127.0.0.1:3000", + callbackUrl: "http://localhost:2233/login", + }, + prod: { + apiUrl: "https://api.folo.is", + callbackUrl: "https://app.folo.is/login", + }, +} as const + +const getArgValue = (name: string) => { + const index = process.argv.indexOf(name) + if (index === -1) { + return null + } + + return process.argv[index + 1] ?? null +} + +const run = (command: string, args: string[]) => + execFileSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim() + +const tryRun = (command: string, args: string[]) => { + try { + return run(command, args) + } catch { + return null + } +} + +const resolveBootedIOSDevice = () => { + const output = tryRun("xcrun", ["simctl", "list", "devices", "booted"]) + if (!output) { + return null + } + + const match = output.match(/\(([A-F0-9-]{36})\) \(Booted\)/) + return match?.[1] ?? null +} + +const escapeSql = (value: string) => value.replaceAll("'", "''") + +const splitSetCookieHeader = (header: string) => { + const parts: string[] = [] + let buffer = "" + let index = 0 + + while (index < header.length) { + const char = header[index] + + if (char === ",") { + const recent = buffer.toLowerCase() + const hasExpires = recent.includes("expires=") + const hasGmt = /gmt/i.test(recent) + + if (hasExpires && !hasGmt) { + buffer += char + index += 1 + continue + } + + if (buffer.trim()) { + parts.push(buffer.trim()) + buffer = "" + } + + index += 1 + if (header[index] === " ") { + index += 1 + } + continue + } + + buffer += char + index += 1 + } + + if (buffer.trim()) { + parts.push(buffer.trim()) + } + + return parts +} + +const toCookieMap = (setCookieHeader: string): CookieMap => { + const cookies = splitSetCookieHeader(setCookieHeader) + const now = Date.now() + const cookieMap: CookieMap = {} + + for (const cookie of cookies) { + const parts = cookie.split(";").map((part) => part.trim()) + const [nameValue, ...attributes] = parts + if (!nameValue) { + continue + } + + const [name, ...valueParts] = nameValue.split("=") + if (!name) { + continue + } + + const value = valueParts.join("=") + let expires: string | null = null + + for (const attribute of attributes) { + const [rawAttrName, ...rawAttrValueParts] = attribute.split("=") + const attrName = rawAttrName?.toLowerCase() + const attrValue = rawAttrValueParts.join("=") + + if (attrName === "max-age") { + const maxAge = Number(attrValue) + if (!Number.isNaN(maxAge)) { + expires = new Date(now + maxAge * 1000).toISOString() + } + } + + if (!expires && attrName === "expires") { + const parsed = new Date(attrValue) + if (!Number.isNaN(parsed.getTime())) { + expires = parsed.toISOString() + } + } + } + + cookieMap[name] = { + value, + expires, + } + } + + return cookieMap +} + +const parseJson = async (response: Response): Promise => { + const text = await response.text() + if (!text) { + return null + } + + return JSON.parse(text) as T +} + +const requestAuth = async ({ + apiUrl, + body, + clientId, + path, + sessionId, +}: { + apiUrl: string + body: Record + clientId: string + path: string + sessionId: string +}) => { + const response = await fetch(new URL(path, apiUrl), { + method: "POST", + headers: { + "content-type": "application/json", + "x-client-id": clientId, + "x-session-id": sessionId, + "x-token": "ac:fallback", + }, + body: JSON.stringify(body), + }) + + const json = await parseJson(response) + + return { + response, + json, + setCookie: response.headers.get("set-cookie"), + } +} + +const signIn = (input: { + apiUrl: string + clientId: string + email: string + password: string + sessionId: string +}) => + requestAuth({ + apiUrl: input.apiUrl, + path: "/better-auth/sign-in/email", + clientId: input.clientId, + sessionId: input.sessionId, + body: { + email: input.email, + password: input.password, + rememberMe: true, + }, + }) + +const signUp = (input: { + apiUrl: string + callbackUrl: string + clientId: string + email: string + password: string + sessionId: string +}) => + requestAuth({ + apiUrl: input.apiUrl, + path: "/better-auth/sign-up/email", + clientId: input.clientId, + sessionId: input.sessionId, + body: { + email: input.email, + password: input.password, + name: input.email.split("@")[0] ?? input.email, + callbackURL: input.callbackUrl, + }, + }) + +const assertAuthSuccess = ({ + action, + response, + result, +}: { + action: string + response: Response + result: AuthResponse | null +}) => { + if (response.ok && !result?.error) { + return + } + + const message = + result?.error?.message || `${action} failed with ${response.status} ${response.statusText}` + throw new Error(message) +} + +const upsertStorageValue = (dbPath: string, key: string, value: string) => { + const sql = `INSERT OR REPLACE INTO storage(key, value) VALUES('${escapeSql(key)}', '${escapeSql(value)}');` + run("sqlite3", [dbPath, sql]) +} + +const main = async () => { + const envProfile = process.env.EXPO_PUBLIC_E2E_ENV_PROFILE === "local" ? "local" : "prod" + const envDefaults = envProfileDefaults[envProfile] + const apiUrl = process.env.E2E_API_URL ?? envDefaults.apiUrl + const bundleId = process.env.E2E_BUNDLE_ID ?? DEFAULT_BUNDLE_ID + const callbackUrl = process.env.E2E_CALLBACK_URL ?? envDefaults.callbackUrl + const email = process.env.E2E_EMAIL ?? `folo-self-test-ios-${envProfile}-${Date.now()}@gmail.com` + const password = process.env.E2E_PASSWORD ?? DEFAULT_PASSWORD + const deviceId = + getArgValue("--udid") ?? + process.env.MAESTRO_IOS_DEVICE_ID ?? + process.env.IOS_UDID ?? + resolveBootedIOSDevice() + + if (!deviceId) { + throw new Error("Missing iOS simulator UDID. Pass --udid or set MAESTRO_IOS_DEVICE_ID.") + } + + const clientId = process.env.E2E_CLIENT_ID ?? `codex-e2e-${Date.now()}` + const sessionId = process.env.E2E_SESSION_ID ?? `codex-e2e-${Date.now()}` + + let signInResult = await signIn({ + apiUrl, + clientId, + email, + password, + sessionId, + }) + + if (!signInResult.response.ok || signInResult.json?.error || !signInResult.setCookie) { + const signUpResult = await signUp({ + apiUrl, + callbackUrl, + clientId, + email, + password, + sessionId, + }) + + if (!signUpResult.response.ok || signUpResult.json?.error) { + const signInMessage = signInResult.json?.error?.message + const signUpMessage = signUpResult.json?.error?.message + const isExistingAccount = signUpMessage?.toLowerCase().includes("exist") + + if (!isExistingAccount) { + throw new Error( + signUpMessage || signInMessage || `sign up failed with ${signUpResult.response.status}`, + ) + } + } + + signInResult = await signIn({ + apiUrl, + clientId, + email, + password, + sessionId, + }) + } + + assertAuthSuccess({ + action: "sign in", + response: signInResult.response, + result: signInResult.json, + }) + + if (!signInResult.setCookie) { + throw new Error("Missing set-cookie header from sign in response.") + } + + const cookieMap = toCookieMap(signInResult.setCookie) + const sessionToken = SESSION_COOKIE_KEYS.map((key) => cookieMap[key]?.value).find(Boolean) + if (!sessionToken) { + throw new Error("Missing session cookie after sign in.") + } + + const appContainer = run("xcrun", ["simctl", "get_app_container", deviceId, bundleId, "data"]) + const storageDbPath = join(appContainer, "Documents", "SQLite", "ExpoSQLiteStorage") + + if (!existsSync(storageDbPath)) { + throw new Error( + `ExpoSQLiteStorage not found at ${storageDbPath}. Install and launch the app first.`, + ) + } + + upsertStorageValue(storageDbPath, COOKIE_STORAGE_KEY, JSON.stringify(cookieMap)) + upsertStorageValue(storageDbPath, SESSION_TOKEN_STORAGE_KEY, sessionToken) + + tryRun("xcrun", ["simctl", "terminate", deviceId, bundleId]) + + run("xcrun", ["simctl", "launch", deviceId, bundleId]) + + process.stdout.write( + `${JSON.stringify( + { + apiUrl, + bundleId, + deviceId, + email, + password, + storageDbPath, + }, + null, + 2, + )}\n`, + ) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/apps/mobile/src/modules/discover/api.ts b/apps/mobile/src/modules/discover/api.ts index 6e6fb44bc..55247df6f 100644 --- a/apps/mobile/src/modules/discover/api.ts +++ b/apps/mobile/src/modules/discover/api.ts @@ -2,11 +2,19 @@ import { followClient } from "@/src/lib/api-client" import type { DiscoverCategories, Language } from "./constants" +const discoverLanguageMap = { + all: "all", + eng: "en", + cmn: "zh-CN", + fra: "fr-FR", +} as const + export const fetchRsshubPopular = (category: DiscoverCategories, lang: Language) => { + const mappedLanguage = discoverLanguageMap[lang] + return followClient.api.discover.rsshub({ - category: "popular", categories: category === "all" ? "popular" : category, - lang: lang === "all" ? undefined : lang, + ...(mappedLanguage !== "all" && { lang: mappedLanguage }), }) }