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