mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-28 19:37:57 +08:00
Until 1.6.0 the settings sync sent every appearance, general and AI setting except the device-local keys each domain lists: interface language, voice, launch at startup, anonymous data, fonts and the like.93e4a3b48read those lists as the keys to send and swapped omit for pick, on desktop and mobile, so since then only the device-local keys synced across devices and nothing else did, the action language and the AI settings included. Its e2e test checked that the interface language syncs. The lists name the device-local keys again and are renamed to say so. The appearance domain also leaves the spotlight keys in its tab to the spotlight domain, which still syncs only those. actionLanguage and personalizePrompt, whichb0f555266added to the lists, are out of them again, so they sync. The server kept what the clients sent before 1.6.0, so once per account each device first queues the settings it changed from their defaults, before it takes any from the server. While a local change to a tab waits, applyRemoteSettings now leaves that tab alone, as the change log already did; settings still at their defaults take the account's values afterwards. The e2e test now syncs the action language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
948 lines
30 KiB
TypeScript
948 lines
30 KiB
TypeScript
import type { Locator, Page } from "@playwright/test"
|
|
import { expect } from "@playwright/test"
|
|
|
|
import type { TestAccount } from "./account"
|
|
import type { DesktopE2EEnv } from "./env"
|
|
import { buildWebAppURL } from "./env"
|
|
|
|
const ONBOARDING_FEED_URL = "folo://onboarding"
|
|
|
|
const isVisible = async (locator: Locator) => locator.isVisible().catch(() => false)
|
|
const visibleByTestId = (page: Page, testId: string) =>
|
|
page.locator(`[data-testid="${testId}"]:visible`).last()
|
|
const isElectronPage = (page: Page) => page.url().startsWith("app://")
|
|
const optionalRendererResponseTimeout = (page: Page) => (isElectronPage(page) ? 5_000 : 120_000)
|
|
|
|
const hasRenderedAppShell = async (page: Page) => {
|
|
const visibleTestIds = [
|
|
"login-button",
|
|
"login-modal",
|
|
"profile-menu-trigger",
|
|
"timeline-tab-articles",
|
|
"subscription-discover-trigger",
|
|
"discover-form-input",
|
|
"settings-language-select",
|
|
]
|
|
|
|
for (const testId of visibleTestIds) {
|
|
if (
|
|
await visibleByTestId(page, testId)
|
|
.isVisible()
|
|
.catch(() => false)
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
const waitForRenderedAppShell = async (page: Page, timeout = 30_000) => {
|
|
return expect
|
|
.poll(hasRenderedAppShell.bind(null, page), { timeout })
|
|
.toBe(true)
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
}
|
|
|
|
const isAuthenticatedUiReady = async (page: Page) => {
|
|
const profileVisible = await visibleByTestId(page, "profile-menu-trigger")
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const loggedOutUiVisible = await isLoggedOutUiReady(page)
|
|
if (loggedOutUiVisible) {
|
|
return false
|
|
}
|
|
|
|
return profileVisible
|
|
}
|
|
|
|
const isLoggedOutUiReady = async (page: Page) => {
|
|
const loginButtonVisible = await page
|
|
.locator('[data-testid="login-button"]:visible')
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const loginModalVisible = await page
|
|
.locator('[data-testid="login-modal"]:visible')
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const loginInputVisible = await page
|
|
.locator('[data-testid="login-email-input"]:visible')
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const registerInputVisible = await page
|
|
.locator('[data-testid="register-email-input"]:visible')
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
|
|
return loginButtonVisible || loginModalVisible || loginInputVisible || registerInputVisible
|
|
}
|
|
|
|
export const injectRecaptchaToken = async (page: Page, env?: DesktopE2EEnv) => {
|
|
await page.addInitScript(
|
|
(nextEnv) => {
|
|
window.__FOLO_E2E_RECAPTCHA_TOKEN__ = "e2e-token"
|
|
|
|
const originalFetch = globalThis.fetch.bind(globalThis)
|
|
const authEndpoints = [
|
|
"/better-auth/sign-in/email",
|
|
"/better-auth/sign-up/email",
|
|
"/better-auth/forget-password",
|
|
]
|
|
|
|
globalThis.fetch = async (input, init) => {
|
|
const request = input instanceof Request ? input : new Request(input, init)
|
|
const requestURL = new URL(request.url, globalThis.location.origin)
|
|
const shouldInjectToken = authEndpoints.some((path) => requestURL.pathname.includes(path))
|
|
|
|
if (!shouldInjectToken) {
|
|
return originalFetch(input, init)
|
|
}
|
|
|
|
const headers = new Headers(request.headers)
|
|
if (!headers.has("x-token")) {
|
|
headers.set("x-token", "r3:e2e-token")
|
|
}
|
|
|
|
return originalFetch(
|
|
new Request(request, {
|
|
headers,
|
|
credentials: "include",
|
|
}),
|
|
)
|
|
}
|
|
|
|
if (!nextEnv) {
|
|
return
|
|
}
|
|
|
|
const fixedEnv = {
|
|
VITE_API_URL: nextEnv.apiURL,
|
|
VITE_EXTERNAL_API_URL: nextEnv.apiURL,
|
|
VITE_WEB_URL: nextEnv.webURL,
|
|
}
|
|
|
|
const target =
|
|
(globalThis as typeof globalThis & { __followEnv?: Record<string, string> }).__followEnv ??
|
|
{}
|
|
|
|
const proxy = new Proxy(target, {
|
|
get(currentTarget, property, receiver) {
|
|
if (typeof property === "string" && property in fixedEnv) {
|
|
return fixedEnv[property as keyof typeof fixedEnv]
|
|
}
|
|
|
|
return Reflect.get(currentTarget, property, receiver)
|
|
},
|
|
set(currentTarget, property, value, receiver) {
|
|
if (typeof property === "string" && property in fixedEnv) {
|
|
return true
|
|
}
|
|
|
|
return Reflect.set(currentTarget, property, value, receiver)
|
|
},
|
|
ownKeys(currentTarget) {
|
|
return Array.from(new Set([...Reflect.ownKeys(currentTarget), ...Object.keys(fixedEnv)]))
|
|
},
|
|
getOwnPropertyDescriptor(currentTarget, property) {
|
|
if (typeof property === "string" && property in fixedEnv) {
|
|
return {
|
|
configurable: true,
|
|
enumerable: true,
|
|
writable: false,
|
|
value: fixedEnv[property as keyof typeof fixedEnv],
|
|
}
|
|
}
|
|
|
|
return Reflect.getOwnPropertyDescriptor(currentTarget, property)
|
|
},
|
|
})
|
|
|
|
Object.defineProperty(globalThis, "__followEnv", {
|
|
configurable: true,
|
|
enumerable: false,
|
|
get() {
|
|
return proxy
|
|
},
|
|
set() {},
|
|
})
|
|
},
|
|
env ? { apiURL: env.apiURL, webURL: env.webURL } : undefined,
|
|
)
|
|
}
|
|
|
|
export const openWebApp = async (
|
|
page: Page,
|
|
env: DesktopE2EEnv,
|
|
route = "/timeline/articles/all/pending",
|
|
) => {
|
|
await injectRecaptchaToken(page, env)
|
|
await page.goto(buildWebAppURL(env, route), { waitUntil: "domcontentloaded" })
|
|
|
|
if (!(await waitForRenderedAppShell(page))) {
|
|
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {})
|
|
await waitForRenderedAppShell(page)
|
|
}
|
|
}
|
|
|
|
export const waitForAuthenticated = async (page: Page) => {
|
|
await expect.poll(() => isAuthenticatedUiReady(page), { timeout: 90_000 }).toBe(true)
|
|
}
|
|
|
|
export const waitForLoggedOut = async (page: Page) => {
|
|
await expect.poll(() => isLoggedOutUiReady(page), { timeout: 30_000 }).toBe(true)
|
|
}
|
|
|
|
const waitForBetterAuthSessionCookie = async (page: Page, apiURL: string) => {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const cookies = await page.context().cookies(apiURL)
|
|
return cookies.some(
|
|
(cookie) =>
|
|
cookie.name === "better-auth.session_token" ||
|
|
cookie.name === "__Secure-better-auth.session_token",
|
|
)
|
|
},
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
const waitForBetterAuthSessionCookieCleared = async (page: Page, apiURL: string) => {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const cookies = await page.context().cookies(apiURL)
|
|
return !cookies.some(
|
|
(cookie) =>
|
|
cookie.name === "better-auth.session_token" ||
|
|
cookie.name === "__Secure-better-auth.session_token",
|
|
)
|
|
},
|
|
{ timeout: 60_000 },
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
export const ensureLoginModal = async (page: Page) => {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const loginModalVisible = await page
|
|
.locator('[data-testid="login-modal"]:visible')
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const loginButtonVisible = await page
|
|
.locator('[data-testid="login-button"]:visible')
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const loginInputVisible = await page
|
|
.locator('[data-testid="login-email-input"]:visible')
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const registerInputVisible = await page
|
|
.locator('[data-testid="register-email-input"]:visible')
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const credentialProviderVisible = await page
|
|
.locator('[data-testid="login-provider-credential"]:visible')
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const loginDialogVisible = await page
|
|
.locator('[role="dialog"]:visible')
|
|
.filter({ hasText: /Sign in to Folo|Login/ })
|
|
.last()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
|
|
return (
|
|
loginModalVisible ||
|
|
loginButtonVisible ||
|
|
loginInputVisible ||
|
|
registerInputVisible ||
|
|
credentialProviderVisible ||
|
|
loginDialogVisible
|
|
)
|
|
},
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
const ensureCredentialForm = async (page: Page, mode: "register" | "login") => {
|
|
await ensureLoginModal(page)
|
|
|
|
const targetInput = visibleByTestId(
|
|
page,
|
|
mode === "register" ? "register-email-input" : "login-email-input",
|
|
)
|
|
const loginButton = visibleByTestId(page, "login-button")
|
|
const loginModal = visibleByTestId(page, "login-modal")
|
|
const activeDialog = page.locator('[role="dialog"]:visible').last()
|
|
const credentialProvider = visibleByTestId(page, "login-provider-credential")
|
|
const targetForm = visibleByTestId(page, mode === "register" ? "register-form" : "login-form")
|
|
const oppositeForm = visibleByTestId(page, mode === "register" ? "login-form" : "register-form")
|
|
const oppositeFormSwitcher = visibleByTestId(
|
|
page,
|
|
mode === "register" ? "login-switch-register" : "register-switch-login",
|
|
)
|
|
|
|
if (await isVisible(targetInput)) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
(await isVisible(loginButton)) &&
|
|
!(await isVisible(loginModal)) &&
|
|
!(await isVisible(activeDialog))
|
|
) {
|
|
await expect(loginButton).toBeVisible({ timeout: 30_000 })
|
|
await loginButton.click({ noWaitAfter: true })
|
|
}
|
|
|
|
if (await isVisible(targetInput)) {
|
|
return
|
|
}
|
|
|
|
if (!(await isVisible(targetForm)) && !(await isVisible(oppositeForm))) {
|
|
await expect(credentialProvider).toBeVisible({ timeout: 30_000 })
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
await credentialProvider.click({ force: true, timeout: 30_000, noWaitAfter: true })
|
|
if (
|
|
await expect
|
|
.poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), {
|
|
timeout: 5_000,
|
|
})
|
|
.toBe(true)
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => (await isVisible(targetForm)) || (await isVisible(oppositeForm)), {
|
|
timeout: 15_000,
|
|
})
|
|
.toBe(true)
|
|
|
|
if (await isVisible(oppositeForm)) {
|
|
await expect(oppositeFormSwitcher).toBeVisible({ timeout: 30_000 })
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
await oppositeFormSwitcher.click({ force: true, timeout: 10_000, noWaitAfter: true })
|
|
if (
|
|
await targetInput
|
|
.waitFor({ state: "visible", timeout: 5_000 })
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
await expect(targetInput).toBeVisible({ timeout: 30_000 })
|
|
}
|
|
|
|
export const registerWithCredential = async (page: Page, account: TestAccount) => {
|
|
await ensureCredentialForm(page, "register")
|
|
await visibleByTestId(page, "register-email-input").fill(account.email)
|
|
await visibleByTestId(page, "register-password-input").fill(account.password)
|
|
const confirmPasswordInput = visibleByTestId(page, "register-confirm-password-input")
|
|
await confirmPasswordInput.fill(account.password)
|
|
const submit = visibleByTestId(page, "register-submit")
|
|
await expect(submit).toBeEnabled({ timeout: 30_000 })
|
|
|
|
const signUpResponse = page
|
|
.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "POST" &&
|
|
response.url().includes("/better-auth/sign-up/email"),
|
|
{ timeout: optionalRendererResponseTimeout(page) },
|
|
)
|
|
.catch(() => null)
|
|
|
|
const [response] = await Promise.all([
|
|
signUpResponse,
|
|
submit.click({ force: true, noWaitAfter: true }),
|
|
])
|
|
if (response) {
|
|
expect(response.ok()).toBe(true)
|
|
await waitForBetterAuthSessionCookie(page, new URL(response.url()).origin)
|
|
}
|
|
|
|
await waitForAuthenticated(page)
|
|
}
|
|
|
|
export const loginWithCredential = async (page: Page, account: TestAccount) => {
|
|
await ensureCredentialForm(page, "login")
|
|
await visibleByTestId(page, "login-email-input").fill(account.email)
|
|
const passwordInput = visibleByTestId(page, "login-password-input")
|
|
await passwordInput.fill(account.password)
|
|
const submit = visibleByTestId(page, "login-submit")
|
|
await expect(submit).toBeEnabled({ timeout: 30_000 })
|
|
|
|
const signInResponse = page
|
|
.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "POST" &&
|
|
response.url().includes("/better-auth/sign-in/email"),
|
|
{ timeout: optionalRendererResponseTimeout(page) },
|
|
)
|
|
.catch(() => null)
|
|
|
|
const [response] = await Promise.all([
|
|
signInResponse,
|
|
submit.click({ force: true, noWaitAfter: true }),
|
|
])
|
|
if (response) {
|
|
expect(response.ok()).toBe(true)
|
|
await waitForBetterAuthSessionCookie(page, new URL(response.url()).origin)
|
|
}
|
|
|
|
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {})
|
|
await waitForRenderedAppShell(page, 120_000)
|
|
|
|
await waitForAuthenticated(page)
|
|
}
|
|
|
|
export const logoutFromProfileMenu = async (page: Page) => {
|
|
await page.keyboard.press("Escape").catch(() => {})
|
|
await returnToMainShell(page)
|
|
const isElectron = isElectronPage(page)
|
|
const appHomeURL = new URL("/timeline/articles/all/pending", page.url()).toString()
|
|
await visibleByTestId(page, "profile-menu-trigger").click()
|
|
|
|
const signOutResponse = page
|
|
.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "POST" && response.url().includes("/better-auth/sign-out"),
|
|
{ timeout: optionalRendererResponseTimeout(page) },
|
|
)
|
|
.catch(() => null)
|
|
|
|
const [response] = await Promise.all([
|
|
signOutResponse,
|
|
page.getByTestId("profile-menu-logout").click({ force: true, noWaitAfter: true }),
|
|
])
|
|
if (response) {
|
|
expect(response.ok()).toBe(true)
|
|
await waitForBetterAuthSessionCookieCleared(page, new URL(response.url()).origin)
|
|
}
|
|
|
|
// Signing out already navigates the app. Observe that transition before issuing
|
|
// another same-URL navigation, which can race the application's reload.
|
|
const loggedOut = await expect
|
|
.poll(() => isLoggedOutUiReady(page), { timeout: 15_000 })
|
|
.toBe(true)
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
if (loggedOut) return
|
|
|
|
if (isElectron) {
|
|
await page.waitForLoadState("domcontentloaded", { timeout: 30_000 }).catch(() => {})
|
|
} else {
|
|
await page.goto(appHomeURL, { waitUntil: "domcontentloaded", timeout: 30_000 }).catch(() => {})
|
|
}
|
|
await waitForRenderedAppShell(page, 60_000)
|
|
if (!(await isLoggedOutUiReady(page))) {
|
|
if (!isElectron) {
|
|
await page
|
|
.goto(appHomeURL, { waitUntil: "domcontentloaded", timeout: 30_000 })
|
|
.catch(() => {})
|
|
} else {
|
|
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {})
|
|
}
|
|
await waitForRenderedAppShell(page, 60_000)
|
|
}
|
|
|
|
await waitForLoggedOut(page)
|
|
}
|
|
|
|
const waitForMainShell = async (page: Page) => {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const profileVisible = await page
|
|
.getByTestId("profile-menu-trigger")
|
|
.isVisible()
|
|
.catch(() => false)
|
|
const timelineVisible = await page
|
|
.getByTestId("timeline-tab-articles")
|
|
.isVisible()
|
|
.catch(() => false)
|
|
|
|
return profileVisible || timelineVisible
|
|
},
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
const closeVisibleDialog = async (page: Page, timeout = 10_000) => {
|
|
const activeDialog = page.locator('[role="dialog"]:visible').last()
|
|
if (!(await activeDialog.isVisible().catch(() => false))) {
|
|
return
|
|
}
|
|
|
|
await page.keyboard.press("Escape").catch(() => {})
|
|
|
|
if (!(await activeDialog.isVisible().catch(() => false))) {
|
|
return
|
|
}
|
|
|
|
const modalClose = activeDialog.getByTestId("modal-close").first()
|
|
|
|
if (await modalClose.isVisible().catch(() => false)) {
|
|
await modalClose.click({ force: true, timeout: 3_000 }).catch(() => {})
|
|
} else {
|
|
await page.keyboard.press("Escape").catch(() => {})
|
|
}
|
|
|
|
if (await activeDialog.isVisible().catch(() => false)) {
|
|
await page.keyboard.press("Escape").catch(() => {})
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => activeDialog.isVisible().catch(() => false), { timeout })
|
|
.toBe(false)
|
|
}
|
|
|
|
const returnToMainShell = async (page: Page) => {
|
|
const discoverInput = page.getByTestId("discover-form-input")
|
|
if (await discoverInput.isVisible().catch(() => false)) {
|
|
const backButton = page.getByTestId("subview-back")
|
|
|
|
if (await backButton.isVisible().catch(() => false)) {
|
|
await backButton.click()
|
|
} else {
|
|
await page.keyboard.press("Escape").catch(() => {})
|
|
}
|
|
|
|
if (await discoverInput.isVisible().catch(() => false)) {
|
|
if (page.url().startsWith("app://")) {
|
|
await page.keyboard.press("Escape").catch(() => {})
|
|
} else {
|
|
await page
|
|
.goto(new URL("/timeline/articles/all/pending", page.url()).toString(), {
|
|
waitUntil: "domcontentloaded",
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => discoverInput.isVisible().catch(() => false), { timeout: 15_000 })
|
|
.toBe(false)
|
|
}
|
|
|
|
if (!(await waitForRenderedAppShell(page, 15_000)) && !page.url().startsWith("app://")) {
|
|
await page
|
|
.goto(new URL("/timeline/articles/all/pending", page.url()).toString(), {
|
|
waitUntil: "domcontentloaded",
|
|
})
|
|
.catch(() => {})
|
|
await waitForRenderedAppShell(page)
|
|
}
|
|
|
|
await waitForMainShell(page)
|
|
|
|
await closeVisibleDialog(page)
|
|
}
|
|
|
|
const waitForSettingsTabContent = async (page: Page, tab: "general" | "feeds") => {
|
|
if (tab === "general") {
|
|
await expect(page.getByTestId("settings-language-select")).toBeVisible({ timeout: 15_000 })
|
|
return
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => page.locator('[data-testid^="settings-feed-row-"]').count(), {
|
|
timeout: 15_000,
|
|
})
|
|
.toBeGreaterThan(0)
|
|
}
|
|
|
|
export const openSettings = async (page: Page, tab: "general" | "feeds" = "general") => {
|
|
await waitForAuthenticated(page)
|
|
|
|
const settingsModal = page.locator("#setting-modal").first()
|
|
|
|
const openSettingsFromRouter = async () => {
|
|
await returnToMainShell(page)
|
|
const opened = await page
|
|
.evaluate((targetTab) => {
|
|
const router = (
|
|
window as Window & {
|
|
router?: {
|
|
showSettings?: (options?: string | { tab?: string }) => void
|
|
}
|
|
}
|
|
).router
|
|
if (typeof router?.showSettings !== "function") {
|
|
return false
|
|
}
|
|
|
|
router.showSettings({ tab: targetTab })
|
|
return true
|
|
}, tab)
|
|
.catch(() => false)
|
|
|
|
if (!opened) {
|
|
return false
|
|
}
|
|
|
|
await expect(settingsModal).toBeVisible({ timeout: 15_000 })
|
|
return true
|
|
}
|
|
|
|
const openSettingsFromMenu = async () => {
|
|
await returnToMainShell(page)
|
|
const profileTrigger = visibleByTestId(page, "profile-menu-trigger")
|
|
await expect(profileTrigger).toBeVisible({ timeout: 15_000 })
|
|
|
|
const preferencesItem = page.getByTestId("profile-menu-preferences")
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
await profileTrigger.click({ force: true })
|
|
if (
|
|
await preferencesItem
|
|
.waitFor({ state: "visible", timeout: 5_000 })
|
|
.then(() => true)
|
|
.catch(() => false)
|
|
) {
|
|
break
|
|
}
|
|
await page.keyboard.press("Escape").catch(() => {})
|
|
}
|
|
|
|
await expect(preferencesItem).toBeVisible({ timeout: 15_000 })
|
|
await preferencesItem.click()
|
|
|
|
await expect(settingsModal).toBeVisible({ timeout: 15_000 })
|
|
}
|
|
|
|
if (!(await settingsModal.isVisible().catch(() => false))) {
|
|
const openedFromRouter = await openSettingsFromRouter().catch(() => false)
|
|
if (!openedFromRouter) {
|
|
try {
|
|
await openSettingsFromMenu()
|
|
} catch {
|
|
await openSettingsFromMenu()
|
|
}
|
|
}
|
|
}
|
|
|
|
await openSettingsTab(page, tab)
|
|
}
|
|
|
|
export const openSettingsTab = async (page: Page, tab: "general" | "feeds") => {
|
|
const settingsTab = page.getByTestId(`settings-tab-${tab}`)
|
|
await expect(settingsTab).toBeVisible({ timeout: 15_000 })
|
|
|
|
if (tab === "feeds") {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const className = (await settingsTab.getAttribute("class")) ?? ""
|
|
return !className.includes("opacity-50")
|
|
},
|
|
{ timeout: 15_000 },
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
await settingsTab.click()
|
|
await waitForSettingsTabContent(page, tab)
|
|
}
|
|
|
|
export const closeSettings = async (page: Page) => {
|
|
const settingsModal = page.locator("#setting-modal").first()
|
|
if (!(await settingsModal.isVisible().catch(() => false))) {
|
|
return
|
|
}
|
|
|
|
await page.keyboard.press("Escape").catch(() => {})
|
|
|
|
if (await settingsModal.isVisible().catch(() => false)) {
|
|
const modalClose = settingsModal.getByTestId("modal-close").first()
|
|
if (await isVisible(modalClose)) {
|
|
await modalClose.click().catch(() => {})
|
|
}
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => settingsModal.isVisible().catch(() => false), { timeout: 10_000 })
|
|
.toBe(false)
|
|
}
|
|
|
|
// The interface language stays on the device; the action language syncs to the account.
|
|
export const setActionLanguage = async (page: Page, label: string) => {
|
|
const languageValue = label.includes("日本語") ? "ja" : label.includes("English") ? "en" : null
|
|
const syncResponse = languageValue
|
|
? page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === "PATCH" &&
|
|
response.url().includes("/settings/general") &&
|
|
response.status() < 400 &&
|
|
(response.request().postData() ?? "").includes(`"actionLanguage":"${languageValue}"`),
|
|
{ timeout: 120_000 },
|
|
)
|
|
: null
|
|
|
|
await page.getByTestId("settings-action-language-select").click()
|
|
await page.getByRole("option", { name: label }).click()
|
|
|
|
await syncResponse
|
|
await expect(page.getByTestId("settings-action-language-select")).toContainText(label, {
|
|
timeout: 30_000,
|
|
})
|
|
}
|
|
|
|
export const getActionLanguageLabel = async (page: Page) => {
|
|
return visibleByTestId(page, "settings-action-language-select").textContent()
|
|
}
|
|
|
|
export const openOnboardingFeedForm = async (
|
|
page: Page,
|
|
env?: DesktopE2EEnv,
|
|
options?: { electron?: boolean },
|
|
) => {
|
|
const discoverInput = page.getByTestId("discover-form-input")
|
|
if (env && !options?.electron && !page.url().startsWith("app://")) {
|
|
await page.goto(buildWebAppURL(env, "/discover"), { waitUntil: "domcontentloaded" })
|
|
await waitForRenderedAppShell(page, 120_000)
|
|
} else if (!(await discoverInput.isVisible().catch(() => false))) {
|
|
await returnToMainShell(page)
|
|
const discoverTrigger = page.getByTestId("subscription-discover-trigger")
|
|
await expect(discoverTrigger).toBeVisible({ timeout: 15_000 })
|
|
await discoverTrigger.click()
|
|
|
|
if (
|
|
!(await discoverInput.isVisible().catch(() => false)) &&
|
|
env &&
|
|
!options?.electron &&
|
|
!page.url().startsWith("app://")
|
|
) {
|
|
await page.goto(buildWebAppURL(env, "/discover"), { waitUntil: "domcontentloaded" })
|
|
await waitForRenderedAppShell(page, 120_000)
|
|
}
|
|
}
|
|
|
|
await expect(discoverInput).toBeVisible({ timeout: 120_000 })
|
|
await discoverInput.fill("")
|
|
await expect(discoverInput).toHaveValue("", { timeout: 15_000 })
|
|
|
|
const onboardingFeedResponse = page
|
|
.waitForResponse(
|
|
(response) => {
|
|
const url = new URL(response.url())
|
|
return (
|
|
response.request().method() === "GET" &&
|
|
url.pathname === "/feeds" &&
|
|
url.searchParams.get("url") === ONBOARDING_FEED_URL
|
|
)
|
|
},
|
|
{ timeout: 120_000 },
|
|
)
|
|
.catch(() => null)
|
|
|
|
await discoverInput.fill(ONBOARDING_FEED_URL)
|
|
await expect(discoverInput).toHaveValue(ONBOARDING_FEED_URL, { timeout: 15_000 })
|
|
await discoverInput.press("Enter")
|
|
await onboardingFeedResponse
|
|
await expect(visibleByTestId(page, "feed-form-submit")).toBeVisible({ timeout: 120_000 })
|
|
await expect(visibleByTestId(page, "feed-form-submit")).toBeEnabled({ timeout: 120_000 })
|
|
}
|
|
|
|
export const followOnboardingFeed = async (
|
|
page: Page,
|
|
env: DesktopE2EEnv,
|
|
options?: { electron?: boolean },
|
|
) => {
|
|
await openOnboardingFeedForm(page, env, options)
|
|
const onboardingDiscoverCard = page
|
|
.locator("[data-feed-id]")
|
|
.filter({ hasText: "Welcome to Folo" })
|
|
.first()
|
|
const followButton = onboardingDiscoverCard.getByRole("button", { name: /^Follow$/i })
|
|
if (await followButton.isVisible().catch(() => false)) {
|
|
await expect(followButton).toBeEnabled({ timeout: 15_000 })
|
|
await followButton.click()
|
|
}
|
|
await expect(page.getByText("Welcome to Folo").first()).toBeVisible({ timeout: 15_000 })
|
|
}
|
|
|
|
export const dismissFeedForm = async (page: Page) => {
|
|
const cancelButton = visibleByTestId(page, "feed-form-cancel")
|
|
const dialog = page.locator('[role="dialog"]:visible').last()
|
|
|
|
if (!(await dialog.isVisible().catch(() => false))) {
|
|
return
|
|
}
|
|
|
|
const modalClose = dialog.getByTestId("modal-close").first()
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await cancelButton.isVisible().catch(() => false)) ||
|
|
(await modalClose.isVisible().catch(() => false)),
|
|
{ timeout: 15_000 },
|
|
)
|
|
.toBe(true)
|
|
|
|
if (await cancelButton.isVisible().catch(() => false)) {
|
|
await cancelButton.click({ force: true, timeout: 3_000 }).catch(() => {})
|
|
} else {
|
|
await closeVisibleDialog(page)
|
|
}
|
|
|
|
if (
|
|
(await cancelButton.isVisible().catch(() => false)) ||
|
|
(await dialog.isVisible().catch(() => false))
|
|
) {
|
|
await closeVisibleDialog(page)
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => dialog.isVisible().catch(() => false), { timeout: 10_000 })
|
|
.toBe(false)
|
|
}
|
|
|
|
const findSettingsFeedRow = async (page: Page, onboardingFeedId: string | null) => {
|
|
const targetedFeedRow = onboardingFeedId
|
|
? page.getByTestId(`settings-feed-row-${onboardingFeedId}`)
|
|
: null
|
|
const fallbackFeedRow = page
|
|
.locator('[data-testid^="settings-feed-row-"]')
|
|
.filter({
|
|
hasText: "Welcome to Folo",
|
|
})
|
|
.first()
|
|
const settingsViewport = page.locator("#setting-modal [data-radix-scroll-area-viewport]").first()
|
|
|
|
await settingsViewport
|
|
.evaluate((element) => {
|
|
if (element instanceof HTMLElement) {
|
|
element.scrollTop = 0
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
|
|
for (let attempt = 0; attempt < 24; attempt++) {
|
|
if (targetedFeedRow && (await targetedFeedRow.isVisible().catch(() => false))) {
|
|
return targetedFeedRow
|
|
}
|
|
|
|
if (await fallbackFeedRow.isVisible().catch(() => false)) {
|
|
return fallbackFeedRow
|
|
}
|
|
|
|
await settingsViewport.hover().catch(() => {})
|
|
await page.mouse.wheel(0, 1200)
|
|
await page.waitForTimeout(150)
|
|
}
|
|
|
|
return targetedFeedRow && (await targetedFeedRow.count()) > 0 ? targetedFeedRow : fallbackFeedRow
|
|
}
|
|
|
|
export const unsubscribeFirstFeedFromSettings = async (page: Page, _env?: DesktopE2EEnv) => {
|
|
const onboardingFeedItem = page
|
|
.locator("[data-feed-id]")
|
|
.filter({
|
|
hasText: "Welcome to Folo",
|
|
})
|
|
.first()
|
|
const onboardingFeedId =
|
|
(await onboardingFeedItem.count()) > 0
|
|
? await onboardingFeedItem.getAttribute("data-feed-id")
|
|
: null
|
|
|
|
await openSettings(page)
|
|
await openSettingsTab(page, "feeds")
|
|
const feedRow = await findSettingsFeedRow(page, onboardingFeedId)
|
|
|
|
await expect(feedRow).toBeVisible({ timeout: 15_000 })
|
|
await feedRow.scrollIntoViewIfNeeded().catch(() => {})
|
|
const feedRowTestId = await feedRow.getAttribute("data-testid")
|
|
await feedRow.click()
|
|
|
|
const unsubscribeButton = page.getByTestId("feeds-batch-unsubscribe")
|
|
await expect(unsubscribeButton).toBeVisible({ timeout: 15_000 })
|
|
await unsubscribeButton.click()
|
|
await page.getByTestId("confirm-destroy").click()
|
|
|
|
if (feedRowTestId) {
|
|
await expect(page.getByTestId(feedRowTestId)).toHaveCount(0, { timeout: 15_000 })
|
|
} else {
|
|
await expect(feedRow).toHaveCount(0, { timeout: 15_000 })
|
|
}
|
|
}
|
|
|
|
export const expectOnboardingFeedUnsubscribed = async (
|
|
page: Page,
|
|
_env?: DesktopE2EEnv,
|
|
_options?: { electron?: boolean },
|
|
) => {
|
|
await openOnboardingFeedForm(page)
|
|
await expect(page.getByTestId("feed-form-cancel")).toHaveCount(0)
|
|
}
|
|
|
|
export const expectTimelineSwitchAndEntryReadFlow = async (page: Page) => {
|
|
await returnToMainShell(page)
|
|
|
|
const videosTab = page.getByTestId("timeline-tab-videos")
|
|
await videosTab.click()
|
|
await expect(videosTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 })
|
|
|
|
const articlesTab = page.getByTestId("timeline-tab-articles")
|
|
await articlesTab.click()
|
|
await expect(articlesTab).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 })
|
|
await closeVisibleDialog(page)
|
|
await expect
|
|
.poll(async () => page.locator("[data-entry-id]").count(), { timeout: 120_000 })
|
|
.toBeGreaterThan(0)
|
|
|
|
const unreadOnboardingEntry = page
|
|
.locator('[data-entry-id][data-read="false"]:visible')
|
|
.filter({ has: page.locator("a[href]") })
|
|
.first()
|
|
await expect(unreadOnboardingEntry).toBeVisible({ timeout: 15_000 })
|
|
|
|
const onboardingEntryId = await unreadOnboardingEntry.getAttribute("data-entry-id")
|
|
expect(onboardingEntryId).toBeTruthy()
|
|
|
|
const onboardingEntry = page.locator(`[data-entry-id="${onboardingEntryId}"]`)
|
|
const onboardingEntryLink = unreadOnboardingEntry.locator("a[href]").first()
|
|
|
|
await unreadOnboardingEntry.scrollIntoViewIfNeeded().catch(() => {})
|
|
await expect(onboardingEntryLink).toBeVisible({ timeout: 15_000 })
|
|
await onboardingEntryLink.click()
|
|
|
|
const entryRender = page.getByTestId("entry-render")
|
|
await expect(entryRender).toBeVisible({ timeout: 15_000 })
|
|
await expect(onboardingEntry).toHaveAttribute("data-active", "true", { timeout: 15_000 })
|
|
await expect(onboardingEntry).toHaveAttribute("data-read", "true", { timeout: 15_000 })
|
|
|
|
const toggleReadButton = page.getByTestId("command-action-entry-read").last()
|
|
await expect(toggleReadButton).toBeVisible({ timeout: 15_000 })
|
|
await expect(toggleReadButton).toBeEnabled({ timeout: 15_000 })
|
|
|
|
await toggleReadButton.click()
|
|
await expect(onboardingEntry).toHaveAttribute("data-read", "false", { timeout: 15_000 })
|
|
|
|
await toggleReadButton.click()
|
|
await expect(onboardingEntry).toHaveAttribute("data-read", "true", { timeout: 15_000 })
|
|
}
|