diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..defb7e7 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# NTOJ Test Environment Configuration + +# Base URL +BASE_URL=https://tobiichi3227.eu.org:312 + +# Test Account Credentials +TEST_EMAIL=fsdoiujfs@example.org +TEST_PASSWORD=Fsdoiujfs + +# Optional: Admin Account (for management tests) +# ADMIN_EMAIL=admin@example.org +# ADMIN_PASSWORD=AdminPassword diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b50568 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +.env +*.log +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..284ff4f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "NTOJ"] + path = NTOJ + url = https://github.com/TFcis/NTOJ.git diff --git a/NTOJ b/NTOJ new file mode 160000 index 0000000..31acccc --- /dev/null +++ b/NTOJ @@ -0,0 +1 @@ +Subproject commit 31acccc1b661cd73ba97905c75cc537aa465c9c0 diff --git a/README.md b/README.md index d420b29..ced7e63 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,124 @@ -# TOJE2E +# TOJE2E - NTOJ E2E 測試專案 +這是針對 NTOJ (New TOJ) 的端對端測試專案,使用 Playwright 框架。 + +## 專案連結 + +TOJ github repo: https://github.com/TFcis/NTOJ + +TOJ website: https://toj.tfcis.org/oj/ + +TOJ spec: +- https://tobiichi3227.eu.org:312/NTOJ/system/ +- https://tobiichi3227.eu.org:312/NTOJ/std/ +- https://tobiichi3227.eu.org:312/NTOJ/std-manage/ +- https://tobiichi3227.eu.org:312/NTOJ/contest/ +- https://tobiichi3227.eu.org:312/NTOJ/contest-manage/ + +## 專案結構 + +``` +TOJE2E/ +├── tests/ # 測試文件 +├── pages/ # Page Object Models +├── utils/ # 工具函數和測試資料 +├── playwright.config.ts # Playwright 配置 +└── package.json # 依賴和腳本 +``` + +## 安裝 + +```bash +npm install +npx playwright install --with-deps +``` + +## 運行測試 + +### 無頭模式(Headless) +適合在沒有圖形介面或背景運行時使用: + +```bash +npm test +# 或 +npm run test:headless +``` + +### 非無頭模式(Headed - 有視窗) +適合在本地開發時使用,可以看到瀏覽器執行過程: + +```bash +npm run test:headed +``` + +### UI 模式(互動式) +Playwright 的圖形化測試介面,可以逐步執行、除錯: + +```bash +npm run test:ui +``` + +### 除錯模式 +單步執行測試,適合開發和除錯: + +```bash +npm run test:debug +``` + +### 查看測試報告 +測試完成後查看 HTML 報告: + +```bash +npm run test:report +``` + +### 錄製測試(Codegen) +自動生成測試程式碼: + +```bash +npm run test:codegen +``` + +## 測試配置 + +測試目標:https://tobiichi3227.eu.org:312 + +測試帳號配置在 `.env` 檔案中: +- TEST_EMAIL=fsdoiujfs@example.org +- TEST_PASSWORD=Fsdoiujfs + +## 常用場景 + +### 1. 本地開發時執行並觀看 +```bash +npm run test:headed +``` + +### 2. 快速執行所有測試 +```bash +npm test +``` + +### 3. 執行特定測試檔案 +```bash +npx playwright test tests/01-basic.spec.ts +``` + +### 4. 執行特定測試(帶視窗) +```bash +npx playwright test tests/01-basic.spec.ts --headed +``` + +## 測試狀態 + +✅ Playwright 環境設置完成 +✅ 基本測試框架建立 +⚠️ 待開發:登入測試、題目測試、提交測試 + +## 下一步 + +1. 創建登入測試 +2. 創建 Page Object Models +3. 測試題目瀏覽功能 +4. 測試程式碼提交功能 +5. 測試比賽相關功能 \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..4e33095 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "toje2e", + "version": "1.0.0", + "description": "E2E tests for NTOJ using Playwright", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:headless": "playwright test", + "test:ui": "playwright test --ui", + "test:debug": "playwright test --debug", + "test:report": "playwright show-report", + "test:codegen": "playwright codegen https://tobiichi3227.eu.org:312" + }, + "keywords": ["e2e", "playwright", "testing"], + "author": "", + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^22.0.0" + } +} diff --git a/pages/ChallengeListPage.ts b/pages/ChallengeListPage.ts new file mode 100644 index 0000000..c900bca --- /dev/null +++ b/pages/ChallengeListPage.ts @@ -0,0 +1,90 @@ +import { Page, Locator, expect } from '@playwright/test'; + +/** + * Page Object Model for Challenge List page + * URL: /chal + */ +export class ChallengeListPage { + readonly page: Page; + readonly challengeRows: Locator; + readonly accountFilter: Locator; + readonly problemFilter: Locator; + readonly stateFilter: Locator; + readonly compilerFilter: Locator; + + constructor(page: Page) { + this.page = page; + + // Challenge list elements + this.challengeRows = page.locator('tr[data-challenge-id], .challenge-row, tbody tr'); + + // Filter elements + this.accountFilter = page.locator('input[name="account"], #account-filter'); + this.problemFilter = page.locator('input[name="problem"], #problem-filter'); + this.stateFilter = page.locator('select[name="state"], #state-filter'); + this.compilerFilter = page.locator('select[name="compiler"], #compiler-filter'); + } + + /** + * Navigate to the challenge list page + */ + async goto() { + await this.page.goto('/chal'); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Get the latest challenge ID from the list + */ + async getLatestChallengeId(): Promise { + // Look for challenge links in the format /chal/{id} + const firstChallengeLink = this.page.locator('a[href^="/chal/"]').first(); + + if (await firstChallengeLink.count() === 0) { + return null; + } + + const href = await firstChallengeLink.getAttribute('href'); + if (!href) return null; + + const match = href.match(/\/chal\/(\d+)/); + return match ? parseInt(match[1]) : null; + } + + /** + * Click on a challenge to view details + */ + async clickChallenge(challengeId: number) { + await this.page.click(`a[href="/chal/${challengeId}"]`); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Filter challenges by account ID + */ + async filterByAccount(accountId: number) { + if (await this.accountFilter.isVisible()) { + await this.accountFilter.fill(accountId.toString()); + await this.page.keyboard.press('Enter'); + await this.page.waitForLoadState('networkidle'); + } + } + + /** + * Filter challenges by problem ID + */ + async filterByProblem(problemId: number) { + if (await this.problemFilter.isVisible()) { + await this.problemFilter.fill(problemId.toString()); + await this.page.keyboard.press('Enter'); + await this.page.waitForLoadState('networkidle'); + } + } + + /** + * Verify we are on the challenge list page + */ + async verifyOnPage() { + await expect(this.page).toHaveURL(/\/chal/); + } +} diff --git a/pages/ChallengePage.ts b/pages/ChallengePage.ts new file mode 100644 index 0000000..e7b1100 --- /dev/null +++ b/pages/ChallengePage.ts @@ -0,0 +1,142 @@ +import { Page, Locator, expect } from '@playwright/test'; + +/** + * Page Object Model for Challenge Detail page + * URL: /chal/{id} + */ +export class ChallengePage { + readonly page: Page; + readonly verdictBadge: Locator; + readonly runtimeText: Locator; + readonly memoryText: Locator; + readonly scoreText: Locator; + readonly codeBlock: Locator; + readonly subtaskResults: Locator; + readonly testdataResults: Locator; + readonly messageText: Locator; + + constructor(page: Page) { + this.page = page; + + // Challenge result elements + this.verdictBadge = page.locator('.badge, [class*="verdict"], [class*="state"]').first(); + this.runtimeText = page.locator('text=/Runtime|執行時間/'); + this.memoryText = page.locator('text=/Memory|記憶體/'); + this.scoreText = page.locator('text=/Score|分數|Rate/'); + this.codeBlock = page.locator('pre, code, .code-block'); + this.subtaskResults = page.locator('[class*="subtask"]'); + this.testdataResults = page.locator('[class*="testdata"]'); + this.messageText = page.locator('.message, [class*="response"]'); + } + + /** + * Navigate to a specific challenge page + */ + async goto(challengeId: number) { + await this.page.goto(`/chal/${challengeId}`); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Get the verdict/state of the challenge + */ + async getVerdict(): Promise { + // Wait for verdict to be not "Challenging" or "Not Started" + await this.page.waitForTimeout(1000); // Initial wait + + const verdictElement = this.verdictBadge.or( + this.page.locator('text=/AC|WA|TLE|MLE|RE|CE|PE|IE/') + ).first(); + + if (await verdictElement.count() === 0) { + return 'UNKNOWN'; + } + + const verdictText = await verdictElement.textContent(); + return verdictText?.trim() || 'UNKNOWN'; + } + + /** + * Wait for challenge to finish judging + * @param timeoutMs Maximum time to wait in milliseconds + * @param pollIntervalMs How often to check the verdict + */ + async waitForJudgeComplete(timeoutMs: number = 60000, pollIntervalMs: number = 2000) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const verdict = await this.getVerdict(); + + // Check if judging is complete + if (verdict !== 'Challenging' && verdict !== 'Not Started' && verdict !== 'UNKNOWN') { + return verdict; + } + + // Wait before next poll + await this.page.waitForTimeout(pollIntervalMs); + + // Reload the page to get updated status + await this.page.reload({ waitUntil: 'networkidle' }); + } + + throw new Error(`Challenge did not complete within ${timeoutMs}ms`); + } + + /** + * Get runtime and memory usage + */ + async getPerformanceMetrics() { + const metrics = { + runtime: '', + memory: '', + }; + + if (await this.runtimeText.isVisible()) { + const runtimeFull = await this.runtimeText.textContent(); + metrics.runtime = runtimeFull?.match(/\d+/)?.[0] || ''; + } + + if (await this.memoryText.isVisible()) { + const memoryFull = await this.memoryText.textContent(); + metrics.memory = memoryFull?.match(/\d+/)?.[0] || ''; + } + + return metrics; + } + + /** + * Get the score/rate + */ + async getScore(): Promise { + if (await this.scoreText.isVisible()) { + const scoreText = await this.scoreText.textContent(); + const scoreMatch = scoreText?.match(/\d+/); + return scoreMatch?.[0] || '0'; + } + return '0'; + } + + /** + * Verify we are on the challenge page + */ + async verifyOnPage(challengeId: number) { + await expect(this.page).toHaveURL(new RegExp(`/chal/${challengeId}`)); + } + + /** + * Check if subtask results are visible + */ + async hasSubtaskResults(): Promise { + return await this.subtaskResults.count() > 0; + } + + /** + * Get challenge message (for errors, etc.) + */ + async getMessage(): Promise { + if (await this.messageText.isVisible()) { + return await this.messageText.textContent(); + } + return null; + } +} diff --git a/pages/LoginPage.ts b/pages/LoginPage.ts new file mode 100644 index 0000000..277a6ff --- /dev/null +++ b/pages/LoginPage.ts @@ -0,0 +1,81 @@ +import { Page, Locator, expect } from '@playwright/test'; + +/** + * Page Object Model for Login/Sign page + * URL: /sign + */ +export class LoginPage { + readonly page: Page; + readonly emailInput: Locator; + readonly passwordInput: Locator; + readonly loginButton: Locator; + readonly registerTab: Locator; + readonly registerNameInput: Locator; + readonly registerEmailInput: Locator; + readonly registerPasswordInput: Locator; + readonly registerButton: Locator; + + constructor(page: Page) { + this.page = page; + + // Login form elements + this.emailInput = page.locator('input[name="email"], input[type="email"]').first(); + this.passwordInput = page.locator('input[name="password"], input[type="password"]').first(); + this.loginButton = page.locator('button:has-text("登入"), button:has-text("Login")').first(); + + // Register form elements + this.registerTab = page.locator('text=註冊, text=Register').first(); + this.registerNameInput = page.locator('input[name="name"]'); + this.registerEmailInput = page.locator('input[name="email"]').nth(1); + this.registerPasswordInput = page.locator('input[name="password"]').nth(1); + this.registerButton = page.locator('button:has-text("註冊"), button:has-text("Register")'); + } + + /** + * Navigate to the login page + */ + async goto() { + await this.page.goto('/sign'); + } + + /** + * Login with email and password + */ + async login(email: string, password: string) { + await this.emailInput.fill(email); + await this.passwordInput.fill(password); + await this.loginButton.click(); + + // Wait for navigation after login + await this.page.waitForLoadState('networkidle'); + } + + /** + * Register a new account + */ + async register(name: string, email: string, password: string) { + await this.registerTab.click(); + await this.registerNameInput.fill(name); + await this.registerEmailInput.fill(email); + await this.registerPasswordInput.fill(password); + await this.registerButton.click(); + + // Wait for navigation after registration + await this.page.waitForLoadState('networkidle'); + } + + /** + * Check if login was successful by verifying user is redirected + */ + async verifyLoginSuccess() { + // After successful login, user should be redirected away from /sign page + await expect(this.page).not.toHaveURL(/\/sign/); + } + + /** + * Check if login failed (still on sign page with error) + */ + async verifyLoginFailure() { + await expect(this.page).toHaveURL(/\/sign/); + } +} diff --git a/pages/ProblemPage.ts b/pages/ProblemPage.ts new file mode 100644 index 0000000..561ccc9 --- /dev/null +++ b/pages/ProblemPage.ts @@ -0,0 +1,80 @@ +import { Page, Locator, expect } from '@playwright/test'; + +/** + * Page Object Model for Problem Detail page + * URL: /pro/{id} + */ +export class ProblemPage { + readonly page: Page; + readonly problemTitle: Locator; + readonly problemContent: Locator; + readonly submitButton: Locator; + readonly timeLimitText: Locator; + readonly memoryLimitText: Locator; + readonly subtaskInfo: Locator; + + constructor(page: Page) { + this.page = page; + + // Problem detail elements + this.problemTitle = page.locator('h1, h2').first(); + this.problemContent = page.locator('#problem-content, .problem-content, iframe'); + this.submitButton = page.locator('a:has-text("提交"), a:has-text("Submit"), button:has-text("提交"), button:has-text("Submit")'); + this.timeLimitText = page.locator('text=/Time Limit|時間限制/'); + this.memoryLimitText = page.locator('text=/Memory Limit|記憶體限制/'); + this.subtaskInfo = page.locator('text=/Subtask|子任務/'); + } + + /** + * Navigate to a specific problem page + */ + async goto(problemId: number) { + await this.page.goto(`/pro/${problemId}`); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Click the submit button to go to submission page + */ + async goToSubmit() { + await this.submitButton.click(); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Verify we are on the problem page + */ + async verifyOnPage(problemId: number) { + await expect(this.page).toHaveURL(new RegExp(`/pro/${problemId}`)); + } + + /** + * Check if problem has content loaded + */ + async verifyProblemLoaded() { + // Wait for either the content div or iframe to be visible + await expect( + this.problemContent.or(this.page.locator('iframe')) + ).toBeVisible({ timeout: 10000 }); + } + + /** + * Get problem limits information + */ + async getLimits() { + const limits = { + timeLimit: '', + memoryLimit: '', + }; + + if (await this.timeLimitText.isVisible()) { + limits.timeLimit = await this.timeLimitText.textContent() || ''; + } + + if (await this.memoryLimitText.isVisible()) { + limits.memoryLimit = await this.memoryLimitText.textContent() || ''; + } + + return limits; + } +} diff --git a/pages/ProblemSetPage.ts b/pages/ProblemSetPage.ts new file mode 100644 index 0000000..411c885 --- /dev/null +++ b/pages/ProblemSetPage.ts @@ -0,0 +1,88 @@ +import { Page, Locator, expect } from '@playwright/test'; + +/** + * Page Object Model for Problem Set page + * URL: /proset + */ +export class ProblemSetPage { + readonly page: Page; + readonly problemLinks: Locator; + readonly searchInput: Locator; + readonly proClassSelect: Locator; + + constructor(page: Page) { + this.page = page; + + // Problem list elements + this.problemLinks = page.locator('a[href^="/pro/"]'); + this.searchInput = page.locator('input[placeholder*="搜尋"], input[placeholder*="Search"]'); + this.proClassSelect = page.locator('select'); + } + + /** + * Navigate to the problem set page + */ + async goto() { + await this.page.goto('/proset'); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Get all visible problems + */ + async getVisibleProblems() { + return await this.problemLinks.all(); + } + + /** + * Click on a problem by its ID + */ + async clickProblem(problemId: number) { + await this.page.click(`a[href="/pro/${problemId}"]`); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Search for problems by name + */ + async searchProblem(searchText: string) { + if (await this.searchInput.isVisible()) { + await this.searchInput.fill(searchText); + await this.page.keyboard.press('Enter'); + await this.page.waitForLoadState('networkidle'); + } + } + + /** + * Select a ProClass filter + */ + async selectProClass(proClassName: string) { + if (await this.proClassSelect.isVisible()) { + await this.proClassSelect.selectOption({ label: proClassName }); + await this.page.waitForLoadState('networkidle'); + } + } + + /** + * Verify we are on the problem set page + */ + async verifyOnPage() { + await expect(this.page).toHaveURL(/\/proset/); + } + + /** + * Get the first problem ID from the list + */ + async getFirstProblemId(): Promise { + const firstProblem = this.problemLinks.first(); + if (await firstProblem.count() === 0) { + return null; + } + + const href = await firstProblem.getAttribute('href'); + if (!href) return null; + + const match = href.match(/\/pro\/(\d+)/); + return match ? parseInt(match[1]) : null; + } +} diff --git a/pages/SubmitPage.ts b/pages/SubmitPage.ts new file mode 100644 index 0000000..8a73b15 --- /dev/null +++ b/pages/SubmitPage.ts @@ -0,0 +1,110 @@ +import { Page, Locator, expect } from '@playwright/test'; + +/** + * Page Object Model for Problem Submission page + * URL: /submit/{id} + */ +export class SubmitPage { + readonly page: Page; + readonly compilerSelect: Locator; + readonly codeTextarea: Locator; + readonly submitButton: Locator; + readonly errorMessage: Locator; + + constructor(page: Page) { + this.page = page; + + // Submit form elements + this.compilerSelect = page.locator('select[name="compiler"], #compiler-select, select').first(); + this.codeTextarea = page.locator('textarea[name="code"], textarea#code, .CodeMirror textarea, textarea').first(); + this.submitButton = page.locator('button:has-text("提交"), button:has-text("Submit"), input[type="submit"]').first(); + this.errorMessage = page.locator('.error, .alert-danger, [class*="error"]'); + } + + /** + * Navigate to submit page for a specific problem + */ + async goto(problemId: number) { + await this.page.goto(`/submit/${problemId}`); + await this.page.waitForLoadState('networkidle'); + } + + /** + * Select a compiler + */ + async selectCompiler(compiler: string) { + await this.compilerSelect.selectOption({ label: compiler }); + } + + /** + * Fill in the code + * Note: This might need adjustment if the site uses CodeMirror or Monaco editor + */ + async fillCode(code: string) { + // Check if CodeMirror is being used + const codeMirror = this.page.locator('.CodeMirror'); + if (await codeMirror.count() > 0) { + // CodeMirror editor - need to use execCommand + await codeMirror.click(); + await this.page.evaluate((codeText) => { + const cm = (window as any).CodeMirror; + if (cm && cm.instances && cm.instances.length > 0) { + cm.instances[0].setValue(codeText); + } + }, code); + } else { + // Regular textarea + await this.codeTextarea.fill(code); + } + } + + /** + * Submit the code + */ + async submit() { + await this.submitButton.click(); + + // Wait for navigation or response + try { + await this.page.waitForLoadState('networkidle', { timeout: 5000 }); + } catch (e) { + // Ignore timeout if page doesn't navigate + } + } + + /** + * Submit code with compiler selection + */ + async submitCode(compiler: string, code: string) { + await this.selectCompiler(compiler); + await this.fillCode(code); + await this.submit(); + } + + /** + * Verify we are on the submit page + */ + async verifyOnPage(problemId: number) { + await expect(this.page).toHaveURL(new RegExp(`/submit/${problemId}`)); + } + + /** + * Check if there's an error message + */ + async getErrorMessage(): Promise { + if (await this.errorMessage.isVisible()) { + return await this.errorMessage.textContent(); + } + return null; + } + + /** + * Wait for submission cooldown message to disappear + */ + async waitForCooldown(timeoutMs: number = 31000) { + const cooldownMessage = this.page.locator('text=/冷卻|cooldown|請稍候/i'); + if (await cooldownMessage.isVisible()) { + await cooldownMessage.waitFor({ state: 'hidden', timeout: timeoutMs }); + } + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..ae6c2e6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,70 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for NTOJ E2E tests + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests', + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['html'], + ['list'] + ], + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'https://tobiichi3227.eu.org:312', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + /* Screenshot on failure */ + screenshot: 'only-on-failure', + + /* Video on failure */ + video: 'retain-on-failure', + + /* Ignore HTTPS errors for test server */ + ignoreHTTPSErrors: true, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // Uncomment to test on other browsers + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], + + /* Timeout settings */ + timeout: 60000, // 60 seconds for each test + expect: { + timeout: 10000, // 10 seconds for assertions + }, +}); diff --git a/tests/01-basic.spec.ts b/tests/01-basic.spec.ts new file mode 100644 index 0000000..8f09e5b --- /dev/null +++ b/tests/01-basic.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Basic Website Tests', () => { + test('should load homepage', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/NTOJ|TOJ/i); + }); + + test('should access sign page', async ({ page }) => { + await page.goto('/sign'); + await expect(page).toHaveURL(/\/sign/); + }); +}); diff --git a/tests/01-login.spec.ts b/tests/01-login.spec.ts new file mode 100644 index 0000000..47a1aa2 --- /dev/null +++ b/tests/01-login.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { TEST_USER } from '../utils/test-data'; + +/** + * Test Suite: User Authentication + * Tests for login and registration functionality + */ +test.describe('User Authentication', () => { + test('should login successfully with valid credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + + await loginPage.goto(); + await loginPage.login(TEST_USER.email, TEST_USER.password); + await loginPage.verifyLoginSuccess(); + + // Verify user menu or logout option is visible + await expect(page.locator('text=/Leave|登出|帳號/i')).toBeVisible(); + }); + + test('should fail login with invalid credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + + await loginPage.goto(); + await loginPage.login('invalid@example.org', 'wrongpassword'); + + // Should remain on login page + await loginPage.verifyLoginFailure(); + }); + + test('should display login page correctly', async ({ page }) => { + const loginPage = new LoginPage(page); + + await loginPage.goto(); + + // Verify form elements are visible + await expect(loginPage.emailInput).toBeVisible(); + await expect(loginPage.passwordInput).toBeVisible(); + await expect(loginPage.loginButton).toBeVisible(); + }); +}); diff --git a/tests/02-problemset.spec.ts b/tests/02-problemset.spec.ts new file mode 100644 index 0000000..c944435 --- /dev/null +++ b/tests/02-problemset.spec.ts @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { ProblemSetPage } from '../pages/ProblemSetPage'; +import { ProblemPage } from '../pages/ProblemPage'; +import { TEST_USER } from '../utils/test-data'; + +/** + * Test Suite: Problem Set + * Tests for browsing and viewing problems + */ +test.describe('Problem Set', () => { + // Login before each test + test.beforeEach(async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(TEST_USER.email, TEST_USER.password); + await loginPage.verifyLoginSuccess(); + }); + + test('should display problem list', async ({ page }) => { + const problemSetPage = new ProblemSetPage(page); + + await problemSetPage.goto(); + await problemSetPage.verifyOnPage(); + + // Verify problems are displayed + const problems = await problemSetPage.getVisibleProblems(); + expect(problems.length).toBeGreaterThan(0); + }); + + test('should navigate to problem detail page', async ({ page }) => { + const problemSetPage = new ProblemSetPage(page); + const problemPage = new ProblemPage(page); + + await problemSetPage.goto(); + + // Get first problem ID + const problemId = await problemSetPage.getFirstProblemId(); + expect(problemId).not.toBeNull(); + + if (problemId) { + // Click on the problem + await problemSetPage.clickProblem(problemId); + + // Verify we are on problem page + await problemPage.verifyOnPage(problemId); + await problemPage.verifyProblemLoaded(); + } + }); + + test('should display problem details correctly', async ({ page }) => { + const problemSetPage = new ProblemSetPage(page); + const problemPage = new ProblemPage(page); + + await problemSetPage.goto(); + + const problemId = await problemSetPage.getFirstProblemId(); + + if (problemId) { + await problemPage.goto(problemId); + + // Verify problem content is loaded + await problemPage.verifyProblemLoaded(); + + // Verify submit button is visible + await expect(problemPage.submitButton).toBeVisible(); + + // Get problem limits (optional, may not be visible on all problems) + const limits = await problemPage.getLimits(); + console.log('Problem limits:', limits); + } + }); +}); diff --git a/tests/03-submit.spec.ts b/tests/03-submit.spec.ts new file mode 100644 index 0000000..a0616ef --- /dev/null +++ b/tests/03-submit.spec.ts @@ -0,0 +1,159 @@ +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { ProblemSetPage } from '../pages/ProblemSetPage'; +import { ProblemPage } from '../pages/ProblemPage'; +import { SubmitPage } from '../pages/SubmitPage'; +import { ChallengePage } from '../pages/ChallengePage'; +import { ChallengeListPage } from '../pages/ChallengeListPage'; +import { TEST_USER, CODE_SAMPLES, VERDICTS } from '../utils/test-data'; + +/** + * Test Suite: Code Submission + * Tests for submitting code and checking judge results + * + * Note: These tests will actually submit code and be judged by the system + */ +test.describe('Code Submission', () => { + let problemId: number; + + // Login before all tests + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(TEST_USER.email, TEST_USER.password); + + // Get first available problem + const problemSetPage = new ProblemSetPage(page); + await problemSetPage.goto(); + const firstProblemId = await problemSetPage.getFirstProblemId(); + + if (firstProblemId) { + problemId = firstProblemId; + } + + await context.close(); + }); + + test.beforeEach(async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(TEST_USER.email, TEST_USER.password); + await loginPage.verifyLoginSuccess(); + }); + + test('should display submit page correctly', async ({ page }) => { + test.skip(!problemId, 'No problem available for testing'); + + const submitPage = new SubmitPage(page); + + await submitPage.goto(problemId); + await submitPage.verifyOnPage(problemId); + + // Verify form elements are visible + await expect(submitPage.compilerSelect).toBeVisible(); + await expect(submitPage.codeTextarea).toBeVisible(); + await expect(submitPage.submitButton).toBeVisible(); + }); + + test('should submit C++ code and get judged', async ({ page }) => { + test.skip(!problemId, 'No problem available for testing'); + test.setTimeout(120000); // 2 minutes timeout for judging + + const submitPage = new SubmitPage(page); + const challengeListPage = new ChallengeListPage(page); + const challengePage = new ChallengePage(page); + + // Go to submit page + await submitPage.goto(problemId); + + // Submit code + await submitPage.submitCode('G++', CODE_SAMPLES.cpp_aplusb); + + // Should redirect to challenge list or challenge detail + // Wait a moment for redirect + await page.waitForTimeout(2000); + + // Get the latest challenge ID + await challengeListPage.goto(); + const challengeId = await challengeListPage.getLatestChallengeId(); + + expect(challengeId).not.toBeNull(); + + if (challengeId) { + // Go to challenge detail page + await challengePage.goto(challengeId); + + // Wait for judging to complete + console.log('Waiting for judge to complete...'); + const verdict = await challengePage.waitForJudgeComplete(90000, 3000); + + console.log(`Judge result: ${verdict}`); + + // Verdict should be one of the valid states + expect(verdict).toBeTruthy(); + expect(['AC', 'WA', 'TLE', 'MLE', 'RE', 'CE', 'PE', 'IE']).toContain(verdict); + } + }); + + test('should submit Python code and get judged', async ({ page }) => { + test.skip(!problemId, 'No problem available for testing'); + test.setTimeout(120000); // 2 minutes timeout for judging + + const submitPage = new SubmitPage(page); + const challengeListPage = new ChallengeListPage(page); + const challengePage = new ChallengePage(page); + + // Go to submit page + await submitPage.goto(problemId); + + // Wait for cooldown if needed (30 seconds from previous submission) + await submitPage.waitForCooldown(); + + // Submit Python code + await submitPage.submitCode('Python', CODE_SAMPLES.python_aplusb); + + // Wait for redirect + await page.waitForTimeout(2000); + + // Get the latest challenge ID + await challengeListPage.goto(); + const challengeId = await challengeListPage.getLatestChallengeId(); + + if (challengeId) { + await challengePage.goto(challengeId); + + console.log('Waiting for judge to complete...'); + const verdict = await challengePage.waitForJudgeComplete(90000, 3000); + + console.log(`Judge result: ${verdict}`); + expect(verdict).toBeTruthy(); + } + }); + + test('should respect submission cooldown time', async ({ page }) => { + test.skip(!problemId, 'No problem available for testing'); + + const submitPage = new SubmitPage(page); + + // First submission + await submitPage.goto(problemId); + await submitPage.submitCode('G++', CODE_SAMPLES.cpp_aplusb); + + // Wait a moment + await page.waitForTimeout(2000); + + // Try to submit again immediately + await submitPage.goto(problemId); + await submitPage.fillCode(CODE_SAMPLES.cpp_aplusb); + await submitPage.submit(); + + // Should see cooldown message or error + const errorMsg = await submitPage.getErrorMessage(); + console.log('Cooldown error message:', errorMsg); + + // Note: This test may need adjustment based on actual cooldown implementation + }); +}); diff --git a/tests/auth.setup.ts b/tests/auth.setup.ts new file mode 100644 index 0000000..b96df57 --- /dev/null +++ b/tests/auth.setup.ts @@ -0,0 +1,31 @@ +import { test as setup, expect } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { TEST_USER } from '../utils/test-data'; + +const authFile = 'playwright/.auth/user.json'; + +/** + * Setup test to authenticate user and save the session + * This will run before other tests and save the authentication state + */ +setup('authenticate', async ({ page }) => { + const loginPage = new LoginPage(page); + + // Navigate to login page + await loginPage.goto(); + + // Perform login + await loginPage.login(TEST_USER.email, TEST_USER.password); + + // Verify login success + await loginPage.verifyLoginSuccess(); + + // Optional: Verify that we can see user-specific elements + // For example, check for logout button or user menu + await expect(page.locator('text=/Leave|登出|帳號/i')).toBeVisible({ timeout: 10000 }); + + // Save signed-in state to file + await page.context().storageState({ path: authFile }); + + console.log('✅ Authentication successful, session saved'); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..acfb382 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022", "DOM"], + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node", "@playwright/test"] + }, + "include": ["tests/**/*", "pages/**/*", "utils/**/*"] +} diff --git a/utils/test-data.ts b/utils/test-data.ts new file mode 100644 index 0000000..38dda5b --- /dev/null +++ b/utils/test-data.ts @@ -0,0 +1,67 @@ +/** + * Test data and credentials for E2E tests + */ + +export const TEST_USER = { + email: process.env.TEST_EMAIL || 'fsdoiujfs@example.org', + password: process.env.TEST_PASSWORD || 'Fsdoiujfs', +}; + +export const BASE_URL = process.env.BASE_URL || 'https://tobiichi3227.eu.org:312'; + +/** + * Sample code submissions for testing + */ +export const CODE_SAMPLES = { + // Simple A+B problem solution in C++ + cpp_aplusb: `#include +using namespace std; + +int main() { + int a, b; + cin >> a >> b; + cout << a + b << endl; + return 0; +}`, + + // Python A+B solution + python_aplusb: `a, b = map(int, input().split()) +print(a + b)`, + + // Wrong answer example + cpp_wrong: `#include +using namespace std; + +int main() { + int a, b; + cin >> a >> b; + cout << 0 << endl; // Always output 0 (wrong answer) + return 0; +}`, + + // Time Limit Exceeded example + cpp_tle: `#include +using namespace std; + +int main() { + int a, b; + cin >> a >> b; + while(true) {} // Infinite loop + return 0; +}`, +}; + +/** + * Expected verdict states + */ +export const VERDICTS = { + AC: 'AC', + WA: 'WA', + TLE: 'TLE', + MLE: 'MLE', + RE: 'RE', + CE: 'CE', + IE: 'IE', + CHALLENGING: 'Challenging', + NOT_STARTED: 'Not Started', +} as const;