mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
384 lines
10 KiB
TypeScript
384 lines
10 KiB
TypeScript
import { expect, type Page, type TestInfo } from '@playwright/test'
|
|
import { csrfHeaders } from './csrf'
|
|
|
|
const password = 'Passw0rd!123'
|
|
const cachedUserByWorker = new Map<number, string>()
|
|
const cachedSessionByAccount = new Map<string, SessionSnapshot>()
|
|
const requestTimeoutMs = process.env.CI ? 12_000 : 8_000
|
|
|
|
export interface TestCredentials {
|
|
password: string
|
|
username: string
|
|
}
|
|
|
|
interface RegisterSessionOptions {
|
|
allowMockSession?: boolean
|
|
}
|
|
|
|
interface SessionSnapshot {
|
|
username: string
|
|
cookies: Array<{
|
|
name: string
|
|
value: string
|
|
domain: string
|
|
path: string
|
|
expires: number
|
|
httpOnly: boolean
|
|
secure: boolean
|
|
sameSite: 'Strict' | 'Lax' | 'None'
|
|
}>
|
|
}
|
|
|
|
const cachedSessionByWorker = new Map<number, SessionSnapshot>()
|
|
|
|
function usernameForWorker(testInfo?: TestInfo): string {
|
|
const worker = testInfo?.parallelIndex ?? 0
|
|
return `e2e_worker_${worker}`
|
|
}
|
|
|
|
function uniqueUsernameForWorker(testInfo?: TestInfo): string {
|
|
const worker = testInfo?.parallelIndex ?? 0
|
|
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
|
return `e2e_w${worker}_${suffix}`
|
|
}
|
|
|
|
async function sleep(ms: number): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function isRetryableStatus(status: number): boolean {
|
|
return status === 429 || status >= 500
|
|
}
|
|
|
|
async function loginWithRetry(
|
|
page: Page,
|
|
username: string,
|
|
currentPassword = password,
|
|
retries = process.env.CI ? 10 : 6,
|
|
): Promise<boolean> {
|
|
const request = page.context().request
|
|
for (let i = 0; i < retries; i += 1) {
|
|
try {
|
|
const login = await request.post('/api/v1/auth/local/login', {
|
|
data: { username, password: currentPassword },
|
|
headers: await csrfHeaders(page),
|
|
timeout: requestTimeoutMs,
|
|
})
|
|
|
|
if (login.ok()) {
|
|
return true
|
|
}
|
|
|
|
const status = login.status()
|
|
if (!isRetryableStatus(status)) {
|
|
return false
|
|
}
|
|
} catch {
|
|
// Request context can be transiently unstable in CI startup windows.
|
|
}
|
|
|
|
await sleep(250 * (i + 1))
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
async function hasActiveSession(page: Page): Promise<boolean> {
|
|
try {
|
|
const response = await page.context().request.get('/api/v1/auth/me', {
|
|
timeout: requestTimeoutMs,
|
|
})
|
|
return response.ok()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function cacheSession(page: Page, worker: number, username: string) {
|
|
const snapshot = {
|
|
username,
|
|
cookies: await page.context().cookies(),
|
|
}
|
|
cachedSessionByWorker.set(worker, snapshot)
|
|
cachedSessionByAccount.set(username, snapshot)
|
|
}
|
|
|
|
async function cacheAccountSession(page: Page, username: string) {
|
|
cachedSessionByAccount.set(username, {
|
|
username,
|
|
cookies: await page.context().cookies(),
|
|
})
|
|
}
|
|
|
|
async function restoreCachedSession(page: Page, worker: number): Promise<SessionSnapshot | null> {
|
|
const snapshot = cachedSessionByWorker.get(worker)
|
|
if (!snapshot) {
|
|
return null
|
|
}
|
|
|
|
await page.context().addCookies(snapshot.cookies)
|
|
if (await hasActiveSession(page)) {
|
|
return snapshot
|
|
}
|
|
|
|
cachedSessionByWorker.delete(worker)
|
|
return null
|
|
}
|
|
|
|
async function restoreCachedSessionForAccount(page: Page, username: string): Promise<SessionSnapshot | null> {
|
|
const snapshot = cachedSessionByAccount.get(username)
|
|
if (!snapshot) {
|
|
return null
|
|
}
|
|
|
|
await page.context().addCookies(snapshot.cookies)
|
|
if (await hasActiveSession(page)) {
|
|
return snapshot
|
|
}
|
|
|
|
cachedSessionByAccount.delete(username)
|
|
return null
|
|
}
|
|
|
|
async function primeAuthProviders(page: Page) {
|
|
try {
|
|
await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs })
|
|
} catch {
|
|
// Best effort warm-up.
|
|
}
|
|
}
|
|
|
|
async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ username: string, password: string } | null> {
|
|
try {
|
|
await page.context().request.get('/api/v1/auth/providers', {
|
|
headers: { 'X-Mock-User-Id': 'local-user' },
|
|
timeout: requestTimeoutMs,
|
|
})
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
if (!(await hasActiveSession(page))) {
|
|
return null
|
|
}
|
|
|
|
await cacheSession(page, worker, 'local-user')
|
|
cachedUserByWorker.set(worker, 'local-user')
|
|
return { username: 'local-user', password }
|
|
}
|
|
|
|
async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
|
|
const worker = testInfo?.parallelIndex ?? 0
|
|
const cached = cachedUserByWorker.get(worker)
|
|
const username = usernameForWorker(testInfo)
|
|
const request = page.context().request
|
|
|
|
await primeAuthProviders(page)
|
|
|
|
// Avoid hammering auth endpoints on every test run for the same worker.
|
|
const restored = await restoreCachedSession(page, worker)
|
|
if (restored) {
|
|
cachedUserByWorker.set(worker, restored.username)
|
|
return { username: restored.username, password }
|
|
}
|
|
|
|
if (options?.allowMockSession !== false) {
|
|
const mockSession = await tryBootstrapMockSession(page, worker)
|
|
if (mockSession) {
|
|
return mockSession
|
|
}
|
|
}
|
|
|
|
// Prefer the known-good cached account to avoid repeated failed-logins on a fixed username.
|
|
if (cached && await loginWithRetry(page, cached)) {
|
|
await cacheSession(page, worker, cached)
|
|
return { username: cached, password }
|
|
}
|
|
|
|
// Support environments where a deterministic worker account already exists.
|
|
if (!cached && await loginWithRetry(page, username, password, process.env.CI ? 4 : 3)) {
|
|
cachedUserByWorker.set(worker, username)
|
|
await cacheSession(page, worker, username)
|
|
return { username, password }
|
|
}
|
|
|
|
try {
|
|
const register = await request.post('/api/v1/auth/local/register', {
|
|
data: {
|
|
username,
|
|
password,
|
|
email: `${username}@example.test`,
|
|
},
|
|
headers: await csrfHeaders(page),
|
|
timeout: requestTimeoutMs,
|
|
})
|
|
|
|
if (register.ok()) {
|
|
cachedUserByWorker.set(worker, username)
|
|
await cacheSession(page, worker, username)
|
|
return { username, password }
|
|
}
|
|
|
|
if (register.status() === 409 && await loginWithRetry(page, username, password, process.env.CI ? 8 : 6)) {
|
|
cachedUserByWorker.set(worker, username)
|
|
await cacheSession(page, worker, username)
|
|
return { username, password }
|
|
}
|
|
} catch {
|
|
// Fall through to the unique-account fallback below.
|
|
}
|
|
|
|
// Registering creates session cookies for the current request context.
|
|
// Prefer creating a new unique account to avoid password drift and login throttling.
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const uniqueUsername = `${uniqueUsernameForWorker(testInfo)}_${i}`
|
|
|
|
try {
|
|
const register = await request.post('/api/v1/auth/local/register', {
|
|
data: {
|
|
username: uniqueUsername,
|
|
password,
|
|
email: `${uniqueUsername}@example.test`,
|
|
},
|
|
headers: await csrfHeaders(page),
|
|
timeout: requestTimeoutMs,
|
|
})
|
|
|
|
if (register.ok()) {
|
|
cachedUserByWorker.set(worker, uniqueUsername)
|
|
await cacheSession(page, worker, uniqueUsername)
|
|
return { username: uniqueUsername, password }
|
|
}
|
|
|
|
const status = register.status()
|
|
if (status === 409) {
|
|
continue
|
|
}
|
|
|
|
if (isRetryableStatus(status)) {
|
|
await sleep(300 * (i + 1))
|
|
continue
|
|
}
|
|
|
|
// Username invalidation/conflicts can happen under concurrent CI retries.
|
|
if (status === 400 || status === 409) {
|
|
continue
|
|
}
|
|
|
|
expect(register.ok()).toBeTruthy()
|
|
} catch {
|
|
await sleep(300 * (i + 1))
|
|
}
|
|
}
|
|
|
|
// Final fallback for environments where registration is temporarily unavailable.
|
|
const fallbackCandidates = [cached, username].filter((candidate): candidate is string => Boolean(candidate))
|
|
for (const candidate of fallbackCandidates) {
|
|
if (await loginWithRetry(page, candidate, password, process.env.CI ? 12 : 8)) {
|
|
cachedUserByWorker.set(worker, candidate)
|
|
await cacheSession(page, worker, candidate)
|
|
return { username: candidate, password }
|
|
}
|
|
}
|
|
|
|
throw new Error(`Failed to establish e2e session for worker ${worker}`)
|
|
}
|
|
|
|
async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) {
|
|
const worker = testInfo?.parallelIndex ?? 0
|
|
const request = page.context().request
|
|
|
|
await primeAuthProviders(page)
|
|
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const uniqueUsername = `${uniqueUsernameForWorker(testInfo)}_${i}`
|
|
|
|
try {
|
|
const register = await request.post('/api/v1/auth/local/register', {
|
|
data: {
|
|
username: uniqueUsername,
|
|
password,
|
|
email: `${uniqueUsername}@example.test`,
|
|
},
|
|
headers: await csrfHeaders(page),
|
|
timeout: requestTimeoutMs,
|
|
})
|
|
|
|
if (register.ok()) {
|
|
cachedUserByWorker.set(worker, uniqueUsername)
|
|
await cacheSession(page, worker, uniqueUsername)
|
|
return { username: uniqueUsername, password }
|
|
}
|
|
|
|
const status = register.status()
|
|
if (status === 409 || status === 400) {
|
|
continue
|
|
}
|
|
|
|
if (isRetryableStatus(status)) {
|
|
await sleep(300 * (i + 1))
|
|
continue
|
|
}
|
|
|
|
expect(register.ok()).toBeTruthy()
|
|
} catch {
|
|
await sleep(300 * (i + 1))
|
|
}
|
|
}
|
|
|
|
throw new Error(`Failed to create fresh e2e session for worker ${worker}`)
|
|
}
|
|
|
|
export async function registerSession(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
|
|
let lastError: unknown
|
|
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
try {
|
|
return await registerSessionOnce(page, testInfo, options)
|
|
} catch (error) {
|
|
lastError = error
|
|
if (attempt < 2) {
|
|
await sleep(500 * (attempt + 1))
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError
|
|
}
|
|
|
|
export async function createFreshSession(page: Page, testInfo?: TestInfo) {
|
|
let lastError: unknown
|
|
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
try {
|
|
return await createFreshSessionOnce(page, testInfo)
|
|
} catch (error) {
|
|
lastError = error
|
|
if (attempt < 2) {
|
|
await sleep(500 * (attempt + 1))
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError
|
|
}
|
|
|
|
export async function loginWithCredentials(page: Page, credentials: TestCredentials, _testInfo?: TestInfo) {
|
|
await primeAuthProviders(page)
|
|
|
|
const restored = await restoreCachedSessionForAccount(page, credentials.username)
|
|
if (restored) {
|
|
return credentials
|
|
}
|
|
|
|
const loggedIn = await loginWithRetry(
|
|
page,
|
|
credentials.username,
|
|
credentials.password,
|
|
process.env.CI ? 12 : 8,
|
|
)
|
|
expect(loggedIn).toBeTruthy()
|
|
|
|
await cacheAccountSession(page, credentials.username)
|
|
return credentials
|
|
}
|