feat: complete server-backed six-lesson app and trial tooling
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"repositoryUrl": "https://get.feep.ir/gold_land/gold_land_server.git",
|
||||
"checkoutPath": "backend-nest",
|
||||
"localRef": "",
|
||||
"notes": "CI: set variable E2E_BACKEND_REF to backend commit SHA (required). Local clone: set E2E_BACKEND_REF or localRef. Local sibling: set E2E_BACKEND_ROOT to ../backend-nest and skip ci:checkout-backend."
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||
|
||||
test.describe('store, game, educational challenge', () => {
|
||||
test('store purchase and duplicate guard', async ({ page }) => {
|
||||
const user = uniqueUsername('store')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const me0 = await fetchMe(page)
|
||||
const coins0 = me0.user.coins
|
||||
|
||||
await page.goto('/store')
|
||||
await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => {})
|
||||
await page.getByRole('button', { name: 'تقویت XP' }).click()
|
||||
await page.getByRole('button', { name: 'خرید' }).click()
|
||||
|
||||
await expect(page.getByText(/تقویت XP.*خریده شد/)).toBeVisible({ timeout: 15_000 })
|
||||
const me1 = await fetchMe(page)
|
||||
expect(me1.user.coins).toBeLessThan(coins0)
|
||||
expect(me1.progress.ownedItems).toContain('boost-xp')
|
||||
|
||||
await page.reload()
|
||||
const meRefresh = await fetchMe(page)
|
||||
expect(meRefresh.progress.ownedItems).toContain('boost-xp')
|
||||
|
||||
await expect(page.locator('.product-card').filter({ hasText: 'تقویت XP' })).toContainText('مال توئه')
|
||||
const me2 = await fetchMe(page)
|
||||
expect(me2.user.coins).toBe(me1.user.coins)
|
||||
})
|
||||
|
||||
test('gold guess first completion only', async ({ page }) => {
|
||||
const user = uniqueUsername('game')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const me0 = await fetchMe(page)
|
||||
const coins0 = me0.user.coins
|
||||
const xp0 = me0.user.xp
|
||||
|
||||
await page.goto('/games/gold-guess')
|
||||
await page.getByText('در حال بارگذاری پاداش').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => {})
|
||||
await page.getByRole('button', { name: 'افزایش' }).click()
|
||||
await expect(page.locator('.toast--success')).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const me1 = await fetchMe(page)
|
||||
expect(me1.user.coins).toBeGreaterThan(coins0)
|
||||
expect(me1.progress.claimedRewards).toContain('gold-guess')
|
||||
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: 'افزایش' }).click({ timeout: 5000 }).catch(() => {})
|
||||
const me2 = await fetchMe(page)
|
||||
expect(me2.user.coins).toBe(me1.user.coins)
|
||||
expect(me2.user.xp).toBeGreaterThanOrEqual(xp0)
|
||||
})
|
||||
|
||||
test('educational challenge once', async ({ page }) => {
|
||||
const user = uniqueUsername('challenge')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const me0 = await fetchMe(page)
|
||||
const coins0 = me0.user.coins
|
||||
|
||||
await page.goto('/challenge')
|
||||
await page.getByRole('heading', { name: 'چالش آموزشی' }).waitFor()
|
||||
await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => {})
|
||||
|
||||
await page.getByRole('button', { name: 'طلا' }).click()
|
||||
await page.getByRole('button', { name: 'دریافت پاداش' }).click()
|
||||
|
||||
await expect(page.getByText('پاداش دریافت شد')).toBeVisible({ timeout: 15_000 })
|
||||
const me1 = await fetchMe(page)
|
||||
expect(me1.user.coins).toBeGreaterThan(coins0)
|
||||
expect(me1.progress.completedChallenges).toContain('daily-challenge')
|
||||
|
||||
await page.reload()
|
||||
await page.getByRole('heading', { name: 'چالش آموزشی' }).waitFor()
|
||||
const me2 = await fetchMe(page)
|
||||
expect(me2.user.coins).toBe(me1.user.coins)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Valid UI choices for level 1 (what-is-money) — matches backend grader keys. */
|
||||
export const level1ChoiceLabels = [
|
||||
'مبادله آسانتر کالاها',
|
||||
'درست',
|
||||
'چون همه همیشه همان چیز را میخواهند',
|
||||
'درست',
|
||||
'پول وسیلهٔ مبادله و نگهداری ارزش است',
|
||||
] as const
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export default function globalTeardown() {
|
||||
const statePath = join(process.cwd(), 'e2e', '.runtime', 'state.json')
|
||||
try {
|
||||
const { tempDir } = JSON.parse(readFileSync(statePath, 'utf8')) as { tempDir: string }
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
rmSync(join(process.cwd(), 'e2e', '.runtime'), { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export function uniqueUsername(prefix = 'e2e'): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
export async function registerViaUi(
|
||||
page: Page,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
) {
|
||||
await page.goto('/login')
|
||||
await page.getByRole('button', { name: 'ثبتنام کن' }).click()
|
||||
if (displayName) {
|
||||
await page.getByLabel('نام نمایشی').fill(displayName)
|
||||
}
|
||||
await page.getByLabel('نام کاربری').fill(username)
|
||||
await page.getByLabel('رمز عبور').fill(password)
|
||||
await page.getByRole('button', { name: 'ثبتنام' }).click()
|
||||
await page.waitForURL('/')
|
||||
}
|
||||
|
||||
export async function loginViaUi(page: Page, username: string, password: string) {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('نام کاربری').fill(username)
|
||||
await page.getByLabel('رمز عبور').fill(password)
|
||||
await page.getByRole('button', { name: 'ورود', exact: true }).click()
|
||||
await page.waitForURL('/')
|
||||
}
|
||||
|
||||
export async function logoutViaProfile(page: Page) {
|
||||
await page.goto('/profile')
|
||||
await page.getByRole('button', { name: 'خروج' }).click()
|
||||
await page.waitForURL('/login')
|
||||
}
|
||||
|
||||
export async function getAuthToken(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => localStorage.getItem('gold-land-auth-token'))
|
||||
}
|
||||
|
||||
export async function fetchMe(page: Page) {
|
||||
const token = await getAuthToken(page)
|
||||
if (!token) throw new Error('no auth token')
|
||||
const res = await page.request.get('/api/me', {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok()) throw new Error(`GET /api/me failed: ${res.status()}`)
|
||||
return res.json() as Promise<{
|
||||
user: { xp: number; coins: number; name: string }
|
||||
progress: {
|
||||
mapLevels: Array<{ id: number; stars: number; status: string }>
|
||||
totalStars: number
|
||||
completedChallenges: string[]
|
||||
claimedRewards: string[]
|
||||
ownedItems: string[]
|
||||
}
|
||||
wallet: { cash: number; goldGrams: number }
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { level1ChoiceLabels } from '../fixtures/level1-answers'
|
||||
|
||||
export async function openLevel1FromMap(page: Page) {
|
||||
await page.goto('/map')
|
||||
await page.getByRole('heading', { name: 'سرزمین طلا' }).waitFor()
|
||||
await page.getByRole('button', { name: /مرحله 1:/ }).click()
|
||||
await page.getByRole('button', { name: 'شروع درس' }).click()
|
||||
await page.waitForURL(/\/gold-land\/levels\/1/)
|
||||
await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 })
|
||||
}
|
||||
|
||||
export async function playCurrentLessonSteps(page: Page) {
|
||||
const pending = [...level1ChoiceLabels]
|
||||
|
||||
for (let step = 0; step < 50; step++) {
|
||||
if (await page.getByText('امتیاز').isVisible().catch(() => false)) return
|
||||
|
||||
const finish = page.getByRole('button', { name: 'اتمام درس' })
|
||||
if ((await finish.isVisible().catch(() => false)) && (await finish.isEnabled())) {
|
||||
await finish.click()
|
||||
await page.getByText('امتیاز').waitFor({ timeout: 30_000 })
|
||||
return
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
const label = pending[0]!
|
||||
const chip = page.getByRole('button', { name: label, exact: true })
|
||||
if (await chip.isVisible().catch(() => false)) {
|
||||
await chip.click()
|
||||
pending.shift()
|
||||
}
|
||||
}
|
||||
|
||||
const next = page.getByRole('button', { name: 'بعدی' })
|
||||
if (await next.isEnabled().catch(() => false)) {
|
||||
await next.click()
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('lesson steps did not complete')
|
||||
}
|
||||
|
||||
export async function completeLevel1Lesson(page: Page) {
|
||||
await openLevel1FromMap(page)
|
||||
await playCurrentLessonSteps(page)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
fetchMe,
|
||||
loginViaUi,
|
||||
logoutViaProfile,
|
||||
registerViaUi,
|
||||
uniqueUsername,
|
||||
} from './helpers/auth'
|
||||
import { completeLevel1Lesson, playCurrentLessonSteps } from './helpers/lesson'
|
||||
|
||||
test.describe('main user journey', () => {
|
||||
test('register → lesson 1 → map → refresh → logout → login', async ({ page }) => {
|
||||
const user = uniqueUsername('journey')
|
||||
const password = 'pass1234'
|
||||
|
||||
await registerViaUi(page, user, password, 'کاربر E2E')
|
||||
|
||||
const meBefore = await fetchMe(page)
|
||||
const xp0 = meBefore.user.xp
|
||||
const coins0 = meBefore.user.coins
|
||||
|
||||
await completeLevel1Lesson(page)
|
||||
|
||||
await expect(page.getByText('مرحله 2 باز شد')).toBeVisible()
|
||||
const rewardXp = page.locator('.stat-row--reward').filter({ hasText: 'XP' })
|
||||
if (await rewardXp.count()) {
|
||||
await expect(rewardXp).toBeVisible()
|
||||
}
|
||||
|
||||
const meAfterLesson = await fetchMe(page)
|
||||
expect(meAfterLesson.progress.mapLevels.find((l) => l.id === 1)?.stars).toBeGreaterThan(0)
|
||||
expect(meAfterLesson.progress.mapLevels.find((l) => l.id === 2)?.status).not.toBe('locked')
|
||||
expect(meAfterLesson.user.xp).toBeGreaterThan(xp0)
|
||||
expect(meAfterLesson.user.coins).toBeGreaterThan(coins0)
|
||||
|
||||
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
|
||||
await page.waitForURL(/\/map/)
|
||||
|
||||
await page.reload()
|
||||
await page.waitForURL(/\/map/)
|
||||
const meAfterRefresh = await fetchMe(page)
|
||||
expect(meAfterRefresh.progress.mapLevels.find((l) => l.id === 1)?.stars).toBe(
|
||||
meAfterLesson.progress.mapLevels.find((l) => l.id === 1)?.stars,
|
||||
)
|
||||
|
||||
await logoutViaProfile(page)
|
||||
await loginViaUi(page, user, password)
|
||||
const meRelogin = await fetchMe(page)
|
||||
expect(meRelogin.progress.mapLevels.find((l) => l.id === 1)?.stars).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('lesson replay without improvement grants no extra reward in UI', async ({ page }) => {
|
||||
const user = uniqueUsername('replay')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
await completeLevel1Lesson(page)
|
||||
|
||||
const me1 = await fetchMe(page)
|
||||
const xp1 = me1.user.xp
|
||||
const coins1 = me1.user.coins
|
||||
|
||||
await page.getByRole('button', { name: 'مرور دوباره' }).click()
|
||||
await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 })
|
||||
await playCurrentLessonSteps(page)
|
||||
|
||||
const me2 = await fetchMe(page)
|
||||
expect(me2.user.xp).toBe(xp1)
|
||||
expect(me2.user.coins).toBe(coins1)
|
||||
})
|
||||
|
||||
test('locked and coming_soon levels behave correctly', async ({ page }) => {
|
||||
const user = uniqueUsername('maplock')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
await page.goto('/map')
|
||||
await page.getByRole('heading', { name: 'سرزمین طلا' }).waitFor()
|
||||
|
||||
const level2 = page.getByRole('button', { name: /مرحله 2:/ })
|
||||
await expect(level2).toBeDisabled()
|
||||
|
||||
await page.goto('/gold-land/levels/7')
|
||||
await expect(page.getByText('هنوز قفل است')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { tomansForGoldPurchase, tomansForGoldSale } from '../src/utils/goldTrade'
|
||||
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||
|
||||
test.describe('wallet rounding UI vs server', () => {
|
||||
test('micro trade and standard buy/sell match server policy', async ({ page }) => {
|
||||
const user = uniqueUsername('market')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const me0 = await fetchMe(page)
|
||||
const cash0 = me0.wallet.cash
|
||||
const gold0 = me0.wallet.goldGrams
|
||||
|
||||
await page.goto('/market')
|
||||
await page.locator('button.trade-btn--buy').first().click()
|
||||
const buyModal = page.getByRole('dialog')
|
||||
await buyModal.locator('.trade-form__input').fill('0.000003')
|
||||
await expect(page.getByText('برآورد هزینه (خرید به بالا)')).toBeVisible()
|
||||
await buyModal.getByRole('button', { name: 'تأیید خرید' }).click()
|
||||
await expect(page.getByText('خرید با موفقیت انجام شد!').last()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(buyModal).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
let me = await fetchMe(page)
|
||||
expect(me.wallet.goldGrams).toBeCloseTo(gold0 + 0.000_003, 6)
|
||||
expect(me.wallet.cash).toBe(cash0 - tomansForGoldPurchase(0.000_003))
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.locator('button.trade-btn--sell').first().click()
|
||||
const sellModal = page.getByRole('dialog')
|
||||
await sellModal.locator('.trade-form__input').fill('0.000001')
|
||||
await sellModal.getByRole('button', { name: 'تأیید فروش' }).click()
|
||||
await expect(page.getByText('فروش با موفقیت انجام شد!').last()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
await expect(sellModal).toBeHidden({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
me = await fetchMe(page)
|
||||
expect(me.wallet.goldGrams).toBeCloseTo(gold0, 6)
|
||||
expect(me.wallet.cash).toBeLessThanOrEqual(cash0)
|
||||
|
||||
await page.locator('button.trade-btn--buy').first().click()
|
||||
const buyModal2 = page.getByRole('dialog')
|
||||
await buyModal2.locator('.trade-form__input').fill('0.05')
|
||||
await expect(buyModal2.getByText('برآورد هزینه (خرید به بالا)')).toBeVisible()
|
||||
await buyModal2.getByRole('button', { name: 'تأیید خرید' }).click()
|
||||
await expect(page.getByText('خرید با موفقیت انجام شد!').last()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
me = await fetchMe(page)
|
||||
expect(me.wallet.goldGrams).toBeGreaterThan(gold0 + 0.04)
|
||||
})
|
||||
|
||||
test('invalid grams shows server error without changing balance', async ({ page }) => {
|
||||
const user = uniqueUsername('marketbad')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const before = await fetchMe(page)
|
||||
const snap = JSON.stringify(before.wallet)
|
||||
|
||||
await page.goto('/market')
|
||||
await page.locator('button.trade-btn--buy').first().click()
|
||||
await page.locator('.trade-form__input').fill('0.0000004')
|
||||
await page.getByRole('button', { name: 'تأیید خرید' }).click()
|
||||
await expect(page.getByText(/نامعتبر|کمتر از یک تومان|دقت/)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
const after = await fetchMe(page)
|
||||
expect(JSON.stringify(after.wallet)).toBe(snap)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const frontendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:5190'
|
||||
const backendPort = Number(process.env.E2E_BACKEND_PORT ?? 3010)
|
||||
const frontendPort = Number(process.env.E2E_FRONTEND_PORT ?? 5190)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: '**/*.spec.ts',
|
||||
timeout: 120_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
|
||||
globalTeardown: './global-teardown.ts',
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'off',
|
||||
locale: 'fa-IR',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'mobile',
|
||||
use: {
|
||||
...devices['Pixel 7'],
|
||||
...(process.env.PLAYWRIGHT_CHROME_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHROME_CHANNEL as 'chrome' } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'desktop',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1280, height: 800 },
|
||||
...(process.env.PLAYWRIGHT_CHROME_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHROME_CHANNEL as 'chrome' } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
command: 'node scripts/e2e-backend.mjs',
|
||||
cwd: frontendRoot,
|
||||
url: `http://127.0.0.1:${backendPort}/health`,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
{
|
||||
command: `npx vite --port ${frontendPort} --strictPort`,
|
||||
cwd: frontendRoot,
|
||||
port: frontendPort,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_DEV_PORT: String(frontendPort),
|
||||
VITE_DEV_API_TARGET: `http://127.0.0.1:${backendPort}`,
|
||||
VITE_E2E: '1',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { fetchMe, loginViaUi, logoutViaProfile, registerViaUi, uniqueUsername } from './helpers/auth'
|
||||
|
||||
test.describe('session and errors', () => {
|
||||
test('wrong password shows login failure', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('نام کاربری').fill('no_such_user_e2e')
|
||||
await page.getByLabel('رمز عبور').fill('wrongpass')
|
||||
await page.getByRole('button', { name: 'ورود', exact: true }).click()
|
||||
await expect(page.locator('.toast--error')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page).toHaveURL(/login/)
|
||||
})
|
||||
|
||||
test('second account does not show first account data', async ({ page }) => {
|
||||
const u1 = uniqueUsername('acct1')
|
||||
const u2 = uniqueUsername('acct2')
|
||||
await registerViaUi(page, u1, 'pass1234', 'کاربر یک')
|
||||
const me1 = await fetchMe(page)
|
||||
await page.goto('/profile')
|
||||
await expect(page.locator('.user-section__name')).toContainText(me1.user.name ?? 'کاربر یک')
|
||||
|
||||
await logoutViaProfile(page)
|
||||
await page.getByRole('button', { name: 'ثبتنام کن' }).click()
|
||||
await page.getByLabel('نام نمایشی').fill('کاربر دو')
|
||||
await page.getByLabel('نام کاربری').fill(u2)
|
||||
await page.getByLabel('رمز عبور').fill('pass1234')
|
||||
await page.getByRole('button', { name: 'ثبتنام' }).click()
|
||||
await page.waitForURL('/')
|
||||
const me2 = await fetchMe(page)
|
||||
await page.goto('/profile')
|
||||
await expect(page.locator('.user-section__name')).toContainText(me2.user.name)
|
||||
await expect(page.locator('.user-section__name')).not.toContainText('کاربر یک')
|
||||
})
|
||||
|
||||
test('401 clears session to login', async ({ page }) => {
|
||||
const user = uniqueUsername('401')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
await page.goto('/wallet')
|
||||
await page.evaluate(() => localStorage.setItem('gold-land-auth-token', 'invalid-token'))
|
||||
await page.goto('/market')
|
||||
await page.waitForURL('/login', { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('catalog load retry after network failure', async ({ page }) => {
|
||||
const user = uniqueUsername('net')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
|
||||
let storeCalls = 0
|
||||
await page.route('**/api/store/products', async (route) => {
|
||||
storeCalls += 1
|
||||
if (storeCalls === 1) {
|
||||
await route.abort('failed')
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.goto('/store')
|
||||
await expect(page.locator('.store-status--error')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText(/اتصال به سرور|خطا در دریافت/)).toBeVisible()
|
||||
await page.getByRole('button', { name: 'تلاش مجدد' }).click()
|
||||
await page.getByRole('button', { name: 'تقویت XP' }).waitFor({ timeout: 20_000 })
|
||||
expect(storeCalls).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test('bootstrap network error keeps token and retry loads server data', async ({ page }) => {
|
||||
const user = uniqueUsername('boot')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const meOk = await fetchMe(page)
|
||||
|
||||
let meCalls = 0
|
||||
await page.route('**/api/me', async (route) => {
|
||||
meCalls += 1
|
||||
if (meCalls === 1) {
|
||||
await route.abort('failed')
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.reload()
|
||||
await expect(page.getByRole('alert')).toContainText('اتصال به سرور', { timeout: 15_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))).toBeTruthy()
|
||||
|
||||
await page.getByRole('button', { name: 'تلاش مجدد' }).click()
|
||||
await expect(page.getByRole('alert')).toBeHidden({ timeout: 20_000 })
|
||||
|
||||
const meAfter = await fetchMe(page)
|
||||
expect(meAfter.user.xp).toBe(meOk.user.xp)
|
||||
expect(meAfter.user.coins).toBe(meOk.user.coins)
|
||||
expect(meCalls).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { registerViaUi, uniqueUsername } from './helpers/auth'
|
||||
|
||||
test.describe('UI quality', () => {
|
||||
test('no unexpected console errors on home and RTL layout', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text())
|
||||
})
|
||||
|
||||
const user = uniqueUsername('ui')
|
||||
await registerViaUi(page, user, 'pass1234')
|
||||
const bodyDir = await page.evaluate(() => getComputedStyle(document.body).direction)
|
||||
expect(bodyDir).toBe('rtl')
|
||||
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 2)
|
||||
expect(overflow).toBe(false)
|
||||
|
||||
const filtered = errors.filter(
|
||||
(e) => !e.includes('favicon') && !e.includes('DevTools') && !e.includes('404'),
|
||||
)
|
||||
expect(filtered).toEqual([])
|
||||
})
|
||||
|
||||
test('keyboard focus reaches login username field', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel('نام کاربری').focus()
|
||||
await expect(page.getByLabel('نام کاربری')).toBeFocused()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user