fix(mobile): repair discover categories and bootstrap iOS auth (#4912)

This commit is contained in:
DIYgod 2026-03-12 12:27:52 +08:00 committed by GitHub
parent f78d7e766b
commit e8ae098ba0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 519 additions and 6 deletions

View File

@ -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

View File

@ -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

View File

@ -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`

View File

@ -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)

View File

@ -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",

View File

@ -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<string, CookieValue>
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 <T>(response: Response): Promise<T | null> => {
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<string, unknown>
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<AuthResponse>(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
})

View File

@ -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 }),
})
}