feat: complete server-backed six-lesson app and trial tooling
Frontend CI / unit (test, type-check, build) (push) Successful in 1m11s
Frontend CI / browser E2E (frontend + pinned backend) (push) Failing after 2m48s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 14:40:37 +03:30
co-authored by Cursor
parent 653f1b0c5a
commit e3f0457dc9
73 changed files with 3792 additions and 395 deletions
+6
View File
@@ -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."
}
+76
View File
@@ -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)
})
})
+8
View File
@@ -0,0 +1,8 @@
/** Valid UI choices for level 1 (what-is-money) — matches backend grader keys. */
export const level1ChoiceLabels = [
'مبادله آسان‌تر کالاها',
'درست',
'چون همه همیشه همان چیز را می‌خواهند',
'درست',
'پول وسیلهٔ مبادله و نگه‌داری ارزش است',
] as const
+17
View File
@@ -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
}
}
+60
View File
@@ -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 }
}>
}
+47
View File
@@ -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)
}
+82
View File
@@ -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()
})
})
+67
View File
@@ -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)
})
})
+66
View File
@@ -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',
},
},
],
})
+93
View File
@@ -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)
})
})
+31
View File
@@ -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()
})
})