fix(mobile): repair discover categories and bootstrap iOS auth (#4912)
This commit is contained in:
parent
f78d7e766b
commit
e8ae098ba0
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue