Initialize

This commit is contained in:
ChenKaiLiuG
2026-01-29 11:48:45 +08:00
parent 8579a2f732
commit e139daa410
20 changed files with 1228 additions and 1 deletions

12
.env.example Normal file
View File

@@ -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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
.env
*.log
.DS_Store

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "NTOJ"]
path = NTOJ
url = https://github.com/TFcis/NTOJ.git

1
NTOJ Submodule

Submodule NTOJ added at 31acccc1b6

124
README.md
View File

@@ -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. 測試比賽相關功能

21
package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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<number | null> {
// 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/);
}
}

142
pages/ChallengePage.ts Normal file
View File

@@ -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<string> {
// 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<string> {
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<boolean> {
return await this.subtaskResults.count() > 0;
}
/**
* Get challenge message (for errors, etc.)
*/
async getMessage(): Promise<string | null> {
if (await this.messageText.isVisible()) {
return await this.messageText.textContent();
}
return null;
}
}

81
pages/LoginPage.ts Normal file
View File

@@ -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/);
}
}

80
pages/ProblemPage.ts Normal file
View File

@@ -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;
}
}

88
pages/ProblemSetPage.ts Normal file
View File

@@ -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<number | null> {
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;
}
}

110
pages/SubmitPage.ts Normal file
View File

@@ -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<string | null> {
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 });
}
}
}

70
playwright.config.ts Normal file
View File

@@ -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
},
});

13
tests/01-basic.spec.ts Normal file
View File

@@ -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/);
});
});

41
tests/01-login.spec.ts Normal file
View File

@@ -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();
});
});

View File

@@ -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);
}
});
});

159
tests/03-submit.spec.ts Normal file
View File

@@ -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
});
});

31
tests/auth.setup.ts Normal file
View File

@@ -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');
});

15
tsconfig.json Normal file
View File

@@ -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/**/*"]
}

67
utils/test-data.ts Normal file
View File

@@ -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 <iostream>
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 <iostream>
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 <iostream>
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;