Initialize
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "NTOJ"]
|
||||||
|
path = NTOJ
|
||||||
|
url = https://github.com/TFcis/NTOJ.git
|
||||||
1
NTOJ
Submodule
1
NTOJ
Submodule
Submodule NTOJ added at 31acccc1b6
124
README.md
124
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. 測試比賽相關功能
|
||||||
21
package.json
Normal file
21
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
90
pages/ChallengeListPage.ts
Normal file
90
pages/ChallengeListPage.ts
Normal 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
142
pages/ChallengePage.ts
Normal 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
81
pages/LoginPage.ts
Normal 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
80
pages/ProblemPage.ts
Normal 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
88
pages/ProblemSetPage.ts
Normal 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
110
pages/SubmitPage.ts
Normal 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
70
playwright.config.ts
Normal 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
13
tests/01-basic.spec.ts
Normal 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
41
tests/01-login.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
tests/02-problemset.spec.ts
Normal file
73
tests/02-problemset.spec.ts
Normal 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
159
tests/03-submit.spec.ts
Normal 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
31
tests/auth.setup.ts
Normal 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
15
tsconfig.json
Normal 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
67
utils/test-data.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user