Compare commits
47 Commits
e727933e21
...
df05820a95
| Author | SHA1 | Date | |
|---|---|---|---|
| df05820a95 | |||
| d8007fab3d | |||
| e41676fa5e | |||
| 896c1b6dbd | |||
| 24a5ed70ac | |||
| 61854829e8 | |||
| 9fdfdfe571 | |||
| b31fb01862 | |||
| 82e9dc2c86 | |||
| f50a2efdbf | |||
| 67aa98da4f | |||
| 53c974eb25 | |||
| b46e242cb7 | |||
| 5e3678ee67 | |||
| 766f5ef33e | |||
| e8be04c968 | |||
| a26f91c95f | |||
| f5065173b3 | |||
| f0ba028d77 | |||
| 3b30119317 | |||
| e8ffed41ee | |||
| 6d15744735 | |||
| 1ac401908f | |||
| d68bc0214c | |||
| 3b9533a432 | |||
| 350238f84c | |||
| 6d79de92db | |||
| 0d0a8c9a48 | |||
| db9fadd13d | |||
| f97f31acaa | |||
| afe4038778 | |||
| 7e4933ee25 | |||
| c4573726f6 | |||
| e1c78c532d | |||
| 6688fbb4de | |||
| 06a817c416 | |||
| ede8c94560 | |||
| f05c0e3aca | |||
| 828987b8f1 | |||
| acca2d100f | |||
| 9085e69907 | |||
| 2e6db75472 | |||
| efa3e27455 | |||
| be7ed687e4 | |||
| d883dc3dc0 | |||
| adadfffd79 | |||
| 47b9eecf9f |
23
Dockerfile
23
Dockerfile
@@ -1,5 +1,26 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libnss3 \
|
||||
libnspr4 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libdbus-1-3 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libasound2 \
|
||||
libatspi2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
ENV CHROME_DEBUGGING_PORT=9222
|
||||
|
||||
@@ -7,4 +28,4 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install && npm install -g playwright && playwright install chromium
|
||||
COPY . .
|
||||
CMD ["npx", "playwright", "test"]
|
||||
CMD ["bash", "-lc", "node server/proxy.mjs & npx playwright test"]
|
||||
|
||||
109
global-setup.ts
Normal file
109
global-setup.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const WIZARD_URL = process.env.WIZARD_URL || 'http://wizard:8080/wizard';
|
||||
const WIZARD_API_URL = process.env.WIZARD_API_URL || 'http://wizard:8080';
|
||||
|
||||
async function setupWizard() {
|
||||
console.log('🔧 Running global setup: Configure wizard...');
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
// Navigate to frontend - should redirect to wizard since not configured
|
||||
const frontendURL = process.env.FRONTEND_URL || 'http://frontend:3000';
|
||||
console.log(`📱 Navigating to ${frontendURL}...`);
|
||||
|
||||
await page.goto(frontendURL);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Step 0: Welcome
|
||||
console.log('⏳ Waiting for wizard page...');
|
||||
await page.waitForSelector('h1:has-text("HarborForge")', { timeout: 10000 });
|
||||
|
||||
const connectButton = page.locator('button:has-text("Connect to Wizard")');
|
||||
if (await connectButton.isVisible().catch(() => false)) {
|
||||
console.log('🔗 Clicking Connect to Wizard...');
|
||||
await connectButton.click();
|
||||
|
||||
// Wait for the step to change after clicking
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check what's on the page now
|
||||
const pageContent = await page.content();
|
||||
console.log('Page after click:', pageContent.substring(0, 2000));
|
||||
|
||||
// Wait for step 1: Database
|
||||
console.log('📝 Waiting for Database configuration...');
|
||||
try {
|
||||
await page.waitForSelector('h2:has-text("Database configuration")', { timeout: 5000 });
|
||||
} catch (e) {
|
||||
console.log('Could not find Database config, trying alternate selector...');
|
||||
// Try waiting for any h2
|
||||
await page.waitForSelector('h2', { timeout: 5000 });
|
||||
console.log('Found h2:', await page.locator('h2').first().textContent());
|
||||
}
|
||||
|
||||
await page.locator('label:has-text("Host") input').fill('mysql');
|
||||
await page.locator('label:has-text("Port") input').fill('3306');
|
||||
await page.locator('label:has-text("Username") input').fill('harborforge');
|
||||
await page.locator('label:has-text("Password") input').fill('harborforge_pass');
|
||||
await page.locator('label:has-text("Database") input').fill('harborforge');
|
||||
await page.click('button:has-text("Next")');
|
||||
|
||||
// Wait for step 2: Admin
|
||||
console.log('📝 Filling Admin account...');
|
||||
await page.waitForSelector('h2:has-text("Admin account")', { timeout: 10000 });
|
||||
await page.locator('label:has-text("Password") input').fill('admin123');
|
||||
await page.locator('label:has-text("Email") input').fill('admin@test.com');
|
||||
await page.locator('label:has-text("Full name") input').fill('Test Admin');
|
||||
await page.click('button:has-text("Next")');
|
||||
|
||||
// Wait for step 3: Backend URL
|
||||
console.log('📝 Filling Backend URL...');
|
||||
await page.waitForSelector('h2:has-text("Backend URL")', { timeout: 10000 });
|
||||
const backendURL = process.env.BACKEND_URL || 'http://backend:8000';
|
||||
await page.locator('label:has-text("Backend Base URL") input').fill(backendURL);
|
||||
await page.click('button:has-text("Finish setup")');
|
||||
|
||||
// Wait for step 4: Complete
|
||||
console.log('✅ Waiting for setup complete...');
|
||||
await page.waitForSelector('h2:has-text("Setup complete!")', { timeout: 10000 });
|
||||
|
||||
console.log('✅ Wizard configured successfully!');
|
||||
} else {
|
||||
console.log('⚠️ Wizard already configured or on main page');
|
||||
}
|
||||
|
||||
// Wait for backend to be ready (it starts after config file is written)
|
||||
const backendURL = process.env.BACKEND_URL || 'http://backend:8000';
|
||||
console.log('⏳ Waiting for backend to be ready...');
|
||||
const maxRetries = 30;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const resp = await page.request.get(`${backendURL}/docs`, { timeout: 3000 });
|
||||
if (resp.ok()) {
|
||||
console.log('✅ Backend is ready!');
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// backend not ready yet
|
||||
}
|
||||
if (i === maxRetries - 1) {
|
||||
console.warn('⚠️ Backend did not respond after retries, proceeding anyway...');
|
||||
} else {
|
||||
console.log(` Waiting for backend... (${i + 1}/${maxRetries})`);
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('❌ Wizard setup failed:', error);
|
||||
await browser.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default setupWizard;
|
||||
@@ -6,6 +6,7 @@
|
||||
"test:headed": "playwright test --headed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0"
|
||||
"@playwright/test": "^1.40.0",
|
||||
"axios": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
const baseURL = process.env.BASE_URL || 'http://127.0.0.1:3000';
|
||||
const webServerURL = process.env.WEB_SERVER_URL || baseURL;
|
||||
const chromeDebuggingPort = process.env.CHROME_DEBUGGING_PORT || '9222';
|
||||
const baseURL = process.env.FRONTEND_URL || 'http://frontend:3000';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
@@ -14,22 +13,15 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
launchOptions: {
|
||||
args: [`--remote-debugging-port=${chromeDebuggingPort}`],
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
channel: 'chrome',
|
||||
},
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: webServerURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
// Global setup runs before all tests - configure wizard once
|
||||
globalSetup: path.join(__dirname, 'global-setup.ts'),
|
||||
// Global teardown runs after all tests
|
||||
globalTeardown: undefined,
|
||||
});
|
||||
|
||||
55
server/proxy.mjs
Normal file
55
server/proxy.mjs
Normal file
@@ -0,0 +1,55 @@
|
||||
import http from 'http'
|
||||
|
||||
const createProxy = ({ name, listenPort, targetHost, targetPort }) => {
|
||||
http
|
||||
.createServer((req, res) => {
|
||||
const proxyReq = http.request(
|
||||
{
|
||||
hostname: targetHost,
|
||||
port: targetPort,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
},
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers)
|
||||
proxyRes.pipe(res)
|
||||
}
|
||||
)
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
res.statusCode = 502
|
||||
res.end(`Bad gateway: ${err.message}`)
|
||||
})
|
||||
|
||||
req.pipe(proxyReq)
|
||||
})
|
||||
.listen(listenPort, '127.0.0.1', () => {
|
||||
console.log(`${name} proxy listening on 127.0.0.1:${listenPort} -> ${targetHost}:${targetPort}`)
|
||||
})
|
||||
}
|
||||
|
||||
const wizardPort = Number(process.env.WIZARD_PORT || 8080)
|
||||
const backendPort = Number(process.env.BACKEND_PORT || 8000)
|
||||
const frontendPort = Number(process.env.FRONTEND_PORT || 3000)
|
||||
|
||||
createProxy({
|
||||
name: 'wizard',
|
||||
listenPort: wizardPort,
|
||||
targetHost: process.env.WIZARD_HOST || 'wizard',
|
||||
targetPort: wizardPort,
|
||||
})
|
||||
|
||||
createProxy({
|
||||
name: 'backend',
|
||||
listenPort: backendPort,
|
||||
targetHost: process.env.BACKEND_HOST || 'backend',
|
||||
targetPort: backendPort,
|
||||
})
|
||||
|
||||
createProxy({
|
||||
name: 'frontend',
|
||||
listenPort: frontendPort,
|
||||
targetHost: process.env.FRONTEND_HOST || 'frontend',
|
||||
targetPort: frontendPort,
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('has title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/HarborForge/);
|
||||
});
|
||||
148
tests/milestone.spec.ts
Normal file
148
tests/milestone.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || process.env.FRONTEND_URL || 'http://frontend:3000';
|
||||
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const ADMIN_PASSWORD = 'admin123';
|
||||
|
||||
test.describe('Milestone Editor', () => {
|
||||
test('login -> create project -> create and edit milestone through modal', async ({ page }) => {
|
||||
// Step 1: Login (with retry — backend may still be warming up)
|
||||
console.log('🔐 Logging in...');
|
||||
|
||||
const MAX_LOGIN_RETRIES = 3;
|
||||
for (let attempt = 1; attempt <= MAX_LOGIN_RETRIES; attempt++) {
|
||||
console.log(`Login attempt ${attempt}/${MAX_LOGIN_RETRIES}`);
|
||||
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (attempt === 1) {
|
||||
const backendBase = await page.evaluate(() => localStorage.getItem('HF_BACKEND_BASE_URL'));
|
||||
console.log('HF_BACKEND_BASE_URL:', backendBase);
|
||||
}
|
||||
|
||||
await page.fill('input[type="text"], input[name="username"]', ADMIN_USERNAME);
|
||||
await page.fill('input[type="password"], input[name="password"]', ADMIN_PASSWORD);
|
||||
|
||||
const loginPromise = page.waitForResponse(response =>
|
||||
response.url().includes('/auth/token') && response.request().method() === 'POST'
|
||||
, { timeout: 15000 }).catch(() => null);
|
||||
|
||||
await page.click('button[type="submit"], button:has-text("Sign in")');
|
||||
|
||||
const loginResponse = await loginPromise;
|
||||
if (loginResponse) {
|
||||
console.log('Login response status:', loginResponse.status());
|
||||
if (loginResponse.status() === 200) {
|
||||
// Wait for navigation after successful login
|
||||
await page.waitForURL(`${BASE_URL}/**`, { timeout: 10000 }).catch(() => {});
|
||||
await page.waitForLoadState('networkidle');
|
||||
break;
|
||||
}
|
||||
console.log('Login response:', (await loginResponse.text()).substring(0, 200));
|
||||
} else {
|
||||
console.log('No /auth/token response (backend may not be ready)');
|
||||
}
|
||||
|
||||
if (attempt < MAX_LOGIN_RETRIES) {
|
||||
console.log(`Retrying login in 3s...`);
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
}
|
||||
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'));
|
||||
console.log('Token after login:', token ? 'present' : 'missing');
|
||||
console.log('Current URL:', page.url());
|
||||
expect(token, 'Login failed — no token obtained after retries').toBeTruthy();
|
||||
|
||||
await page.waitForSelector('a:has-text("📁 Projects")', { timeout: 10000 });
|
||||
console.log('✅ Logged in');
|
||||
|
||||
// Step 2: Create Project (unique name for this test)
|
||||
console.log('📁 Creating project...');
|
||||
await page.click('a:has-text("📁 Projects")');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.waitForSelector('button:has-text("+ New")', { timeout: 10000 });
|
||||
await page.click('button:has-text("+ New")');
|
||||
await page.waitForSelector('.modal', { timeout: 10000 });
|
||||
|
||||
const projectName = 'Milestone Test Project ' + Date.now();
|
||||
console.log('Creating project with name:', projectName);
|
||||
await page.getByTestId('project-name-input').fill(projectName);
|
||||
await page.getByTestId('project-description-input').fill('Project for milestone testing');
|
||||
|
||||
await page.click('.modal button.btn-primary:has-text("Create")');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for our specific project card to appear
|
||||
await page.waitForSelector(`.project-card:has-text("${projectName}")`, { timeout: 10000 });
|
||||
console.log('✅ Project created');
|
||||
|
||||
// Step 3: Click on OUR project to open it
|
||||
console.log('👆 Opening project...');
|
||||
await page.click(`.project-card:has-text("${projectName}")`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1500);
|
||||
console.log('✅ Project opened');
|
||||
|
||||
// Step 4: Create milestone through frontend
|
||||
console.log('🎯 Creating milestone...');
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Scroll to milestone section
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click the "+ New" button for milestones
|
||||
await page.click('button:has-text("+ New")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Wait for modal
|
||||
await page.waitForSelector('.modal', { timeout: 5000 });
|
||||
console.log('Modal opened');
|
||||
|
||||
// Fill in the milestone title
|
||||
const milestoneName = 'Test Milestone ' + Date.now();
|
||||
console.log('Milestone name:', milestoneName);
|
||||
await page.fill('input[placeholder="Milestone title"]', milestoneName);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click the Create button inside the modal (the one with class btn-primary)
|
||||
await page.locator('.modal button.btn-primary').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for modal to close
|
||||
await page.waitForSelector('.modal', { state: 'detached', timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Step 5: Verify milestone was created
|
||||
console.log('🔍 Verifying milestone...');
|
||||
const headingText = await page.locator('h3').filter({ hasText: /Milestones/i }).textContent();
|
||||
console.log('Heading text:', headingText);
|
||||
const hasMilestone = headingText && /\(1?\d+\)/.test(headingText) && !headingText.includes('(0)');
|
||||
console.log('Has milestone:', hasMilestone);
|
||||
expect(hasMilestone).toBe(true);
|
||||
console.log('✅ Milestone verified in UI');
|
||||
|
||||
// Step 6: Open milestone and edit it via modal
|
||||
console.log('✏️ Editing milestone...');
|
||||
await page.click(`.milestone-item:has-text("${milestoneName}")`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button:has-text("Edit Milestone")');
|
||||
await page.waitForSelector('.modal', { timeout: 10000 });
|
||||
|
||||
const updatedMilestoneName = `${milestoneName} Updated`;
|
||||
await page.getByTestId('milestone-title-input').fill(updatedMilestoneName);
|
||||
await page.click('.modal button.btn-primary:has-text("Save")');
|
||||
await page.waitForSelector('.modal', { state: 'detached', timeout: 10000 });
|
||||
await expect(page.locator(`h2:has-text("${updatedMilestoneName}")`)).toBeVisible({ timeout: 10000 });
|
||||
console.log('✅ Milestone edited');
|
||||
|
||||
console.log('🎉 All milestone tests passed!');
|
||||
});
|
||||
});
|
||||
132
tests/project-editor.spec.ts
Normal file
132
tests/project-editor.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || process.env.FRONTEND_URL || 'http://frontend:3000';
|
||||
|
||||
// Test credentials from globalSetup
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const ADMIN_PASSWORD = 'admin123';
|
||||
|
||||
test.describe('Project Editor', () => {
|
||||
test('login -> create project -> edit project via modal -> delete project -> logout', async ({ page }) => {
|
||||
// Step 1: Login
|
||||
console.log('🔐 Logging in...');
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.fill('input[type="text"], input[name="username"]', ADMIN_USERNAME);
|
||||
await page.fill('input[type="password"], input[name="password"]', ADMIN_PASSWORD);
|
||||
|
||||
// Capture login response
|
||||
const loginPromise = page.waitForResponse(response =>
|
||||
response.url().includes('/auth/token') && response.request().method() === 'POST'
|
||||
, { timeout: 10000 }).catch(() => null);
|
||||
|
||||
await page.click('button[type="submit"], button:has-text("Sign in")');
|
||||
|
||||
const loginResponse = await loginPromise;
|
||||
if (loginResponse) {
|
||||
console.log('Login response status:', loginResponse.status());
|
||||
console.log('Login response:', (await loginResponse.text()).substring(0, 200));
|
||||
} else {
|
||||
console.log('No /auth/token response');
|
||||
}
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(`${BASE_URL}/**`, { timeout: 10000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Debug: check localStorage
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'));
|
||||
console.log('Token after login:', token ? 'present' : 'missing');
|
||||
|
||||
// Debug: check current URL
|
||||
console.log('Current URL:', page.url());
|
||||
|
||||
console.log('✅ Logged in');
|
||||
|
||||
// Step 2: Create Project (unique name for this test)
|
||||
console.log('📁 Creating project...');
|
||||
// Click Projects in sidebar
|
||||
await page.click('a:has-text("📁 Projects")');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Debug: log all buttons on page
|
||||
const buttons = await page.locator('button').allTextContents();
|
||||
console.log('Buttons on page:', buttons);
|
||||
|
||||
// Click create project button - it's "+ New"
|
||||
await page.waitForSelector('button:has-text("+ New")', { timeout: 10000 });
|
||||
await page.click('button:has-text("+ New")');
|
||||
await page.waitForSelector('.modal', { timeout: 10000 });
|
||||
|
||||
const projectName = 'ProjEditor Test ' + Date.now();
|
||||
console.log('Creating project with name:', projectName);
|
||||
await page.getByTestId('project-name-input').fill(projectName);
|
||||
await page.getByTestId('project-description-input').fill('Project for E2E testing');
|
||||
|
||||
await page.click('.modal button.btn-primary:has-text("Create")');
|
||||
|
||||
// Wait a bit for submission
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for our specific project card to appear
|
||||
await page.waitForSelector(`.project-card:has-text("${projectName}")`, { timeout: 10000 });
|
||||
console.log('✅ Project created');
|
||||
|
||||
// Step 3: Edit the created project via modal
|
||||
console.log('✏️ Editing project via modal...');
|
||||
await page.click(`.project-card:has-text("${projectName}")`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.click('button:has-text("Edit")');
|
||||
await page.waitForSelector('.modal', { timeout: 10000 });
|
||||
const updatedDescription = `Updated project description ${Date.now()}`;
|
||||
await page.getByTestId('project-description-input').fill(updatedDescription);
|
||||
await page.click('.modal button.btn-primary:has-text("Save")');
|
||||
await page.waitForSelector('.modal', { state: 'detached', timeout: 10000 });
|
||||
await expect(page.locator(`text=${updatedDescription}`)).toBeVisible({ timeout: 10000 });
|
||||
console.log('✅ Project edited');
|
||||
|
||||
// Step 4: Delete the created project — click OUR project card by name
|
||||
console.log('🗑️ Deleting project...');
|
||||
|
||||
// Look for delete button
|
||||
const deleteBtn = page.locator('button:has-text("Delete")');
|
||||
const deleteBtnVisible = await deleteBtn.isVisible().catch(() => false);
|
||||
console.log('Delete button visible:', deleteBtnVisible);
|
||||
|
||||
if (deleteBtnVisible) {
|
||||
// Set up prompt handler for the confirmation dialog
|
||||
page.on('dialog', async dialog => {
|
||||
console.log('Dialog message:', dialog.message());
|
||||
// Extract project name from the message - format: "Type the project name "XXX" to confirm deletion:"
|
||||
const match = dialog.message().match(/Type the project name "(.*)" to confirm/);
|
||||
if (match) {
|
||||
await dialog.accept(match[1]);
|
||||
} else {
|
||||
await dialog.accept();
|
||||
}
|
||||
});
|
||||
|
||||
await deleteBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('✅ Project deleted');
|
||||
} else {
|
||||
console.log('Delete button not found');
|
||||
}
|
||||
|
||||
// Step 4: Logout
|
||||
console.log('🚪 Logging out...');
|
||||
|
||||
// Click logout button in sidebar
|
||||
await page.click('button:has-text("Log out")');
|
||||
|
||||
// Wait for token to be cleared
|
||||
await page.waitForFunction(() => !localStorage.getItem('token'), { timeout: 10000 });
|
||||
console.log('✅ Logged out');
|
||||
|
||||
// Verify we're logged out by checking for login button
|
||||
await page.waitForSelector('button:has-text("Log in")', { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
263
tests/propose.spec.ts
Normal file
263
tests/propose.spec.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Propose E2E Tests
|
||||
* Covers: create, list, view, edit, accept, reject, reopen flows
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || process.env.FRONTEND_URL || 'http://frontend:3000';
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const ADMIN_PASSWORD = 'admin123';
|
||||
|
||||
test.describe('Propose Management', () => {
|
||||
const login = async (page: any) => {
|
||||
console.log('🔐 Logging in...');
|
||||
const MAX_LOGIN_RETRIES = 3;
|
||||
for (let attempt = 1; attempt <= MAX_LOGIN_RETRIES; attempt++) {
|
||||
console.log(`Login attempt ${attempt}/${MAX_LOGIN_RETRIES}`);
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.fill('input[type="text"], input[name="username"]', ADMIN_USERNAME);
|
||||
await page.fill('input[type="password"], input[name="password"]', ADMIN_PASSWORD);
|
||||
|
||||
const loginPromise = page.waitForResponse(
|
||||
(r: any) => r.url().includes('/auth/token') && r.request().method() === 'POST',
|
||||
{ timeout: 15000 }
|
||||
).catch(() => null);
|
||||
|
||||
await page.click('button[type="submit"], button:has-text("Sign in")');
|
||||
|
||||
const loginResp = await loginPromise;
|
||||
if (loginResp && loginResp.status() === 200) {
|
||||
await page.waitForURL(`${BASE_URL}/**`, { timeout: 10000 }).catch(() => {});
|
||||
await page.waitForLoadState('networkidle');
|
||||
break;
|
||||
}
|
||||
if (attempt < MAX_LOGIN_RETRIES) {
|
||||
console.log('Retrying login in 3s...');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
}
|
||||
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'));
|
||||
expect(token, 'Login failed').toBeTruthy();
|
||||
console.log('✅ Logged in');
|
||||
};
|
||||
|
||||
const createProject = async (page: any, name: string) => {
|
||||
console.log(`📁 Creating project: ${name}`);
|
||||
await page.goto(`${BASE_URL}/projects`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button:has-text("+ New")');
|
||||
await page.waitForSelector('.modal', { timeout: 10000 });
|
||||
await page.fill('.modal input[placeholder*="Project name"]', name);
|
||||
await page.fill('.modal textarea', 'Test project description');
|
||||
await page.click('.modal button:has-text("Create")');
|
||||
await page.waitForTimeout(1000);
|
||||
console.log('✅ Project created');
|
||||
};
|
||||
|
||||
const createMilestone = async (page: any, projectName: string, name: string) => {
|
||||
console.log(`📌 Creating milestone: ${name}`);
|
||||
await page.goto(`${BASE_URL}/projects`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click(`.project-card:has-text("${projectName}")`);
|
||||
await page.waitForURL(/\/projects\/\d+/, { timeout: 10000 });
|
||||
await page.click('button:has-text("+ New")');
|
||||
await page.waitForSelector('.modal', { timeout: 10000 });
|
||||
await page.fill('input[data-testid="milestone-title-input"]', name);
|
||||
await page.click('.modal button:has-text("Create")');
|
||||
await page.waitForTimeout(1000);
|
||||
console.log('✅ Milestone created');
|
||||
};
|
||||
|
||||
test.describe('Propose CRUD Flow', () => {
|
||||
test('create propose -> view -> edit -> list', async ({ page }) => {
|
||||
const TS = Date.now();
|
||||
await login(page);
|
||||
const projectName = `Propose Test Project ${TS}`;
|
||||
await createProject(page, projectName);
|
||||
|
||||
// Navigate to Proposes page
|
||||
await page.goto(`${BASE_URL}/proposes`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Select project filter
|
||||
await page.selectOption('select', { label: projectName });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Create propose
|
||||
const proposeTitle = `Test Propose ${TS}`;
|
||||
const proposeDesc = `Description for propose ${TS}`;
|
||||
|
||||
await page.click('button:has-text("+ New Propose")');
|
||||
await page.waitForSelector('.modal', { timeout: 10000 });
|
||||
|
||||
await page.fill('.modal input[placeholder*="Propose title"]', proposeTitle);
|
||||
await page.fill('.modal textarea', proposeDesc);
|
||||
await page.click('.modal button:has-text("Create")');
|
||||
|
||||
// Wait for list to update
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify in list
|
||||
await expect(page.locator(`.milestone-card:has-text("${proposeTitle}")`)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// View propose detail
|
||||
await page.click(`.milestone-card:has-text("${proposeTitle}")`);
|
||||
await page.waitForURL(/\/proposes\/\d+/, { timeout: 10000 });
|
||||
|
||||
// Verify detail page
|
||||
await expect(page.locator('h2')).toContainText(proposeTitle);
|
||||
await expect(page.locator('.section').filter({ has: page.locator('h3:has-text("Description")') }).locator('p')).toContainText(proposeDesc);
|
||||
await expect(page.locator('.badge:has-text("open")')).toBeVisible();
|
||||
|
||||
// Edit propose
|
||||
await page.click('button:has-text("Edit")');
|
||||
await page.waitForSelector('.modal', { timeout: 10000 });
|
||||
|
||||
const newTitle = `${proposeTitle} (edited)`;
|
||||
await page.fill('.modal input', newTitle);
|
||||
await page.click('.modal button:has-text("Save")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify edit
|
||||
await expect(page.locator('h2')).toContainText(newTitle);
|
||||
|
||||
console.log('✅ Propose CRUD test passed');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Propose Lifecycle Flow', () => {
|
||||
test('accept propose -> creates feature story task', async ({ page }) => {
|
||||
const TS = Date.now();
|
||||
await login(page);
|
||||
const projectName = `Accept Propose Project ${TS}`;
|
||||
await createProject(page, projectName);
|
||||
|
||||
// Create milestone for accept
|
||||
await createMilestone(page, projectName, `Milestone for Accept ${TS}`);
|
||||
|
||||
// Create propose
|
||||
await page.goto(`${BASE_URL}/proposes`);
|
||||
await page.selectOption('select', { label: projectName });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const proposeTitle = `Accept Test Propose ${TS}`;
|
||||
await page.click('button:has-text("+ New Propose")');
|
||||
await page.fill('.modal input', proposeTitle);
|
||||
await page.fill('.modal textarea', 'To be accepted');
|
||||
await page.click('.modal button:has-text("Create")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Open propose detail
|
||||
await page.click(`.milestone-card:has-text("${proposeTitle}")`);
|
||||
await page.waitForURL(/\/proposes\/\d+/, { timeout: 10000 });
|
||||
|
||||
// Accept propose
|
||||
await page.click('button:has-text("Accept")');
|
||||
await page.waitForSelector('.modal:has-text("Accept Propose")', { timeout: 10000 });
|
||||
|
||||
// Select milestone
|
||||
await page.selectOption('.modal select', { label: `Milestone for Accept ${TS}` });
|
||||
await page.click('.modal button:has-text("Confirm Accept")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify status changed
|
||||
await expect(page.locator('.badge:has-text("accepted")')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('button:has-text("View Generated Task")')).toBeVisible();
|
||||
|
||||
console.log('✅ Propose accept test passed');
|
||||
});
|
||||
|
||||
test('reject propose -> can reopen', async ({ page }) => {
|
||||
const TS = Date.now();
|
||||
await login(page);
|
||||
const projectName = `Reject Propose Project ${TS}`;
|
||||
await createProject(page, projectName);
|
||||
|
||||
// Create propose
|
||||
await page.goto(`${BASE_URL}/proposes`);
|
||||
await page.selectOption('select', { label: projectName });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const proposeTitle = `Reject Test Propose ${TS}`;
|
||||
await page.click('button:has-text("+ New Propose")');
|
||||
await page.fill('.modal input', proposeTitle);
|
||||
await page.click('.modal button:has-text("Create")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Open propose detail
|
||||
await page.click(`.milestone-card:has-text("${proposeTitle}")`);
|
||||
await page.waitForURL(/\/proposes\/\d+/, { timeout: 10000 });
|
||||
|
||||
// Reject propose (current UI uses prompt, not modal)
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.type()).toBe('prompt');
|
||||
await dialog.accept('Rejected by automated test');
|
||||
});
|
||||
await page.click('button:has-text("Reject")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify status changed
|
||||
await expect(page.locator('.badge:has-text("rejected")')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Reopen propose (current UI is direct action, no modal)
|
||||
await page.click('button:has-text("Reopen")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify back to open
|
||||
await expect(page.locator('.badge:has-text("open")')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
console.log('✅ Propose reject/reopen test passed');
|
||||
});
|
||||
|
||||
test('edit button hidden for accepted propose', async ({ page }) => {
|
||||
const TS = Date.now();
|
||||
await login(page);
|
||||
const projectName = `Edit Hidden Project ${TS}`;
|
||||
await createProject(page, projectName);
|
||||
await createMilestone(page, projectName, `Milestone ${TS}`);
|
||||
|
||||
// Create and accept propose
|
||||
await page.goto(`${BASE_URL}/proposes`);
|
||||
await page.selectOption('select', { label: projectName });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.click('button:has-text("+ New Propose")');
|
||||
await page.fill('.modal input', `Accept Test ${TS}`);
|
||||
await page.click('.modal button:has-text("Create")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.click(`.milestone-card:has-text("Accept Test")`);
|
||||
await page.waitForURL(/\/proposes\/\d+/, { timeout: 10000 });
|
||||
|
||||
// Accept
|
||||
await page.click('button:has-text("Accept")');
|
||||
await page.waitForSelector('.modal:has-text("Accept Propose")', { timeout: 10000 });
|
||||
await page.selectOption('.modal select', { label: `Milestone ${TS}` });
|
||||
await page.click('.modal button:has-text("Confirm Accept")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify Edit button is hidden
|
||||
await expect(page.locator('button:has-text("Edit")')).not.toBeVisible();
|
||||
await expect(page.locator('.badge:has-text("accepted")')).toBeVisible();
|
||||
|
||||
console.log('✅ Edit button hidden test passed');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Propose Permissions', () => {
|
||||
test('cannot create propose without project selection', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${BASE_URL}/proposes`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Button should be disabled
|
||||
const button = page.locator('button:has-text("+ New Propose")');
|
||||
await expect(button).toBeDisabled();
|
||||
|
||||
console.log('✅ Propose create disabled test passed');
|
||||
});
|
||||
});
|
||||
});
|
||||
182
tests/role-editor.spec.ts
Normal file
182
tests/role-editor.spec.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || process.env.FRONTEND_URL || 'http://frontend:3000';
|
||||
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const ADMIN_PASSWORD = 'admin123';
|
||||
|
||||
test.describe('Role Editor', () => {
|
||||
test('admin cannot edit admin role, can edit guest role, can create and delete temp-tester role', async ({ page }) => {
|
||||
// Step 1: Login
|
||||
console.log('🔐 Logging in...');
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.fill('input[type="text"], input[name="username"]', ADMIN_USERNAME);
|
||||
await page.fill('input[type="password"], input[name="password"]', ADMIN_PASSWORD);
|
||||
await page.click('button[type="submit"], button:has-text("Sign in")');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(`${BASE_URL}/**`, { timeout: 10000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
console.log('✅ Logged in');
|
||||
|
||||
// Step 2: Navigate to Roles
|
||||
console.log('🔐 Navigating to Roles...');
|
||||
await page.click('a:has-text("🔐 Roles")');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for role list to load
|
||||
await page.waitForSelector('.role-editor-page', { timeout: 10000 });
|
||||
|
||||
// Step 3: Check admin and guest roles exist
|
||||
console.log('👀 Checking admin and guest roles exist...');
|
||||
const roleElements = await page.locator('.role-editor-page div[style*="border"] strong').allTextContents();
|
||||
console.log('Roles found:', roleElements);
|
||||
|
||||
const hasAdmin = roleElements.some(r => r.toLowerCase().includes('admin'));
|
||||
const hasGuest = roleElements.some(r => r.toLowerCase().includes('guest'));
|
||||
expect(hasAdmin).toBe(true);
|
||||
expect(hasGuest).toBe(true);
|
||||
console.log('✅ admin and guest roles exist');
|
||||
|
||||
// Step 4: Click on guest role first (simpler test)
|
||||
console.log('🔧 Clicking guest role...');
|
||||
await page.click('.role-editor-page >> text=guest');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Wait for permission checkboxes to load
|
||||
await page.waitForSelector('input[type="checkbox"]', { timeout: 5000 });
|
||||
console.log('Permission checkboxes loaded');
|
||||
|
||||
// Try to save guest role (without changes first to verify save works)
|
||||
console.log('💾 Trying to save guest role...');
|
||||
const saveBtn1 = page.locator('button:has-text("Save Changes")');
|
||||
await saveBtn1.scrollIntoViewIfNeeded();
|
||||
await saveBtn1.click({ force: true });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check for success message
|
||||
const successMsg1 = await page.locator('text=Saved successfully').first().isVisible().catch(() => false);
|
||||
console.log('Success message shown:', successMsg1);
|
||||
expect(successMsg1).toBe(true);
|
||||
console.log('✅ Can modify guest role verified');
|
||||
|
||||
// Step 5: Navigate to Roles again
|
||||
console.log('🔐 Navigating to Roles again...');
|
||||
await page.click('a:has-text("🔐 Roles")');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForSelector('.role-editor-page', { timeout: 10000 });
|
||||
|
||||
// Step 9: Check if temp-tester role exists, if not create it
|
||||
console.log('🔍 Checking for temp-tester role...');
|
||||
const allRoles = await page.locator('.role-editor-page >> text=temp-tester').count();
|
||||
|
||||
if (allRoles > 0) {
|
||||
console.log('✅ temp-tester role already exists');
|
||||
} else {
|
||||
console.log('➕ Creating temp-tester role...');
|
||||
await page.click('button:has-text("+ Create New Role")');
|
||||
await page.waitForSelector('input[placeholder="e.g., developer, manager"]', { timeout: 5000 });
|
||||
|
||||
await page.fill('input[placeholder="e.g., developer, manager"]', 'temp-tester');
|
||||
await page.fill('input[placeholder="Role description"]', 'Temporary tester role');
|
||||
|
||||
const createRoleResponsePromise = page.waitForResponse((response) => {
|
||||
return response.request().method() === 'POST' && /\/roles$/.test(response.url());
|
||||
});
|
||||
|
||||
await page.click('button:has-text("Create")');
|
||||
const createRoleResponse = await createRoleResponsePromise;
|
||||
const createRoleBody = await createRoleResponse.text();
|
||||
console.log('Create role response:', createRoleResponse.status(), createRoleBody);
|
||||
expect([200, 201, 400]).toContain(createRoleResponse.status());
|
||||
if (createRoleResponse.status() === 400) {
|
||||
expect(createRoleBody).toContain('Role already exists');
|
||||
}
|
||||
|
||||
await page.goto(`${BASE_URL}/roles`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForSelector('.role-editor-page', { timeout: 10000 });
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await page.locator('.role-editor-page >> text=temp-tester').count();
|
||||
}, { timeout: 10000 }).toBeGreaterThan(0);
|
||||
console.log('✅ temp-tester role created');
|
||||
}
|
||||
|
||||
// Step 10: Navigate to Roles and verify temp-tester exists
|
||||
console.log('🔐 Final verification - navigating to Roles...');
|
||||
await page.click('a:has-text("🔐 Roles")');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForSelector('.role-editor-page', { timeout: 10000 });
|
||||
|
||||
const finalRolesCount = await page.locator('.role-editor-page >> text=temp-tester').count();
|
||||
expect(finalRolesCount).toBeGreaterThan(0);
|
||||
console.log('✅ temp-tester role exists verified');
|
||||
|
||||
// Step 11: Delete the temp-tester role and verify
|
||||
console.log('🗑️ Deleting temp-tester role...');
|
||||
|
||||
// Navigate to Roles page fresh
|
||||
await page.goto(`${BASE_URL}/roles`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click on temp-tester role to select it
|
||||
await page.click('.role-editor-page >> text=temp-tester');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Wait for permission checkboxes to load
|
||||
await page.waitForSelector('input[type="checkbox"]', { timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Scroll to make delete button visible
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check if delete button is visible
|
||||
const deleteBtn = page.locator('button:has-text("Delete Role")');
|
||||
const deleteBtnVisible = await deleteBtn.isVisible().catch(() => false);
|
||||
console.log('Delete button visible:', deleteBtnVisible);
|
||||
|
||||
if (deleteBtnVisible) {
|
||||
// Set up dialog handler BEFORE clicking delete
|
||||
page.on('dialog', async dialog => {
|
||||
console.log('Dialog message:', dialog.message());
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
// Click delete button
|
||||
await deleteBtn.click();
|
||||
|
||||
// Wait for the delete to complete
|
||||
await page.waitForTimeout(3000);
|
||||
} else {
|
||||
console.log('Delete button not visible - role might be protected');
|
||||
}
|
||||
|
||||
// Navigate away and back to refresh
|
||||
await page.goto(`${BASE_URL}/projects`);
|
||||
await page.waitForTimeout(1000);
|
||||
await page.goto(`${BASE_URL}/roles`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Verify temp-tester is no longer in the list
|
||||
const roleCards = await page.locator('.role-editor-page div[style*="border"]').all();
|
||||
let tempTesterFound = false;
|
||||
for (const card of roleCards) {
|
||||
const cardText = await card.textContent();
|
||||
if (cardText && cardText.toLowerCase().includes('temp-tester')) {
|
||||
tempTesterFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log('temp-tester found after delete:', tempTesterFound);
|
||||
expect(tempTesterFound).toBe(false);
|
||||
console.log('✅ temp-tester role deleted and verified');
|
||||
|
||||
console.log('🎉 All role editor tests passed!');
|
||||
});
|
||||
});
|
||||
209
tests/task.spec.ts
Normal file
209
tests/task.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || process.env.FRONTEND_URL || 'http://frontend:3000';
|
||||
|
||||
const ADMIN_USERNAME = 'admin';
|
||||
const ADMIN_PASSWORD = 'admin123';
|
||||
|
||||
test.describe('Task & Comment Flow', () => {
|
||||
test('login -> create project -> create milestone -> create task -> create task item -> comment -> logout', async ({ page }) => {
|
||||
const TS = Date.now();
|
||||
|
||||
// ── Step 1: Login ──────────────────────────────────────────────────
|
||||
console.log('🔐 Logging in...');
|
||||
const MAX_LOGIN_RETRIES = 3;
|
||||
for (let attempt = 1; attempt <= MAX_LOGIN_RETRIES; attempt++) {
|
||||
console.log(`Login attempt ${attempt}/${MAX_LOGIN_RETRIES}`);
|
||||
await page.goto(`${BASE_URL}/login`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.fill('input[type="text"], input[name="username"]', ADMIN_USERNAME);
|
||||
await page.fill('input[type="password"], input[name="password"]', ADMIN_PASSWORD);
|
||||
|
||||
const loginPromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/auth/token') && r.request().method() === 'POST',
|
||||
{ timeout: 15000 }
|
||||
).catch(() => null);
|
||||
|
||||
await page.click('button[type="submit"], button:has-text("Sign in")');
|
||||
|
||||
const loginResp = await loginPromise;
|
||||
if (loginResp && loginResp.status() === 200) {
|
||||
await page.waitForURL(`${BASE_URL}/**`, { timeout: 10000 }).catch(() => {});
|
||||
await page.waitForLoadState('networkidle');
|
||||
break;
|
||||
}
|
||||
if (attempt < MAX_LOGIN_RETRIES) {
|
||||
console.log('Retrying login in 3s...');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
}
|
||||
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'));
|
||||
expect(token, 'Login failed').toBeTruthy();
|
||||
await page.waitForSelector('a:has-text("📁 Projects")', { timeout: 10000 });
|
||||
console.log('✅ Logged in');
|
||||
|
||||
// ── Step 2: Create Project ─────────────────────────────────────────
|
||||
const projectName = `Task Test Project ${TS}`;
|
||||
console.log(`📁 Creating project: ${projectName}`);
|
||||
await page.click('a:has-text("📁 Projects")');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.waitForSelector('button:has-text("+ New")', { timeout: 10000 });
|
||||
await page.click('button:has-text("+ New")');
|
||||
await page.waitForSelector('.modal', { timeout: 10000 });
|
||||
await page.getByTestId('project-name-input').fill(projectName);
|
||||
await page.getByTestId('project-description-input').fill('Project for task & comment testing');
|
||||
await page.click('.modal button.btn-primary:has-text("Create")');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.waitForSelector(`.project-card:has-text("${projectName}")`, { timeout: 10000 });
|
||||
console.log('✅ Project created');
|
||||
|
||||
// ── Step 3: Open project → Create Milestone ────────────────────────
|
||||
console.log('📌 Opening project & creating milestone...');
|
||||
await page.click(`.project-card:has-text("${projectName}")`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Scroll to milestone section and click "+ New"
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('button:has-text("+ New")');
|
||||
await page.waitForSelector('.modal', { timeout: 5000 });
|
||||
|
||||
const milestoneName = `Task Test Milestone ${TS}`;
|
||||
console.log(`Milestone name: ${milestoneName}`);
|
||||
await page.fill('input[placeholder="Milestone title"]', milestoneName);
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator('.modal button.btn-primary').click();
|
||||
await page.waitForTimeout(2000);
|
||||
await page.waitForSelector('.modal', { state: 'detached', timeout: 5000 }).catch(() => {});
|
||||
|
||||
// Verify milestone created
|
||||
const headingText = await page.locator('h3').filter({ hasText: /Milestones/i }).textContent();
|
||||
expect(headingText).not.toContain('(0)');
|
||||
console.log('✅ Milestone created');
|
||||
|
||||
// ── Step 4: Enter Milestone Detail → Create Task ───────────────────
|
||||
console.log('📝 Entering milestone detail...');
|
||||
await page.click(`.milestone-item:has-text("${milestoneName}")`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for milestone detail page
|
||||
await page.waitForSelector('h2:has-text("🏁")', { timeout: 10000 });
|
||||
console.log('Milestone detail loaded');
|
||||
|
||||
// Click "+ Create Task"
|
||||
await page.click('button:has-text("+ Create Task")');
|
||||
await page.waitForSelector('.modal.task-create-modal', { timeout: 10000 });
|
||||
|
||||
const taskTitle = `Test Task ${TS}`;
|
||||
console.log(`Task title: ${taskTitle}`);
|
||||
await page.getByTestId('task-title-input').fill(taskTitle);
|
||||
await page.getByTestId('task-description-input').fill('Task created by automated test');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
await page.getByTestId('create-task-button').click();
|
||||
await page.waitForSelector('.modal.task-create-modal', { state: 'detached', timeout: 10000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Verify task appears in the Tasks tab
|
||||
await page.click('.tab:has-text("Tasks")');
|
||||
await page.waitForTimeout(1000);
|
||||
const taskRow = page.locator(`td.task-title:has-text("${taskTitle}")`);
|
||||
await expect(taskRow).toBeVisible({ timeout: 10000 });
|
||||
console.log('✅ Task created and verified');
|
||||
|
||||
// Edit the created task via task detail modal
|
||||
console.log('✏️ Editing created task...');
|
||||
await taskRow.click();
|
||||
await page.waitForURL(/\/tasks\/\d+$/, { timeout: 10000 });
|
||||
await page.click('button:has-text("Edit Task")');
|
||||
await page.waitForSelector('.modal.task-create-modal', { timeout: 10000 });
|
||||
const updatedTaskTitle = `${taskTitle} Updated`;
|
||||
await page.getByTestId('task-title-input').fill(updatedTaskTitle);
|
||||
await page.getByTestId('create-task-button').click();
|
||||
await page.waitForSelector('.modal.task-create-modal', { state: 'detached', timeout: 10000 });
|
||||
await expect(page.locator(`h2:has-text("${updatedTaskTitle}")`)).toBeVisible({ timeout: 10000 });
|
||||
console.log('✅ Task edited and verified');
|
||||
|
||||
// ── Step 5: Create a Task item (for comment testing) ───────────────
|
||||
// Milestone tasks don't have a detail page with comments, so we create
|
||||
// a Task item from the shared Tasks-page modal to test the comment flow.
|
||||
console.log('📋 Creating task item for comment testing...');
|
||||
await page.goto(`${BASE_URL}/tasks`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.click('button:has-text("+ Create Task")');
|
||||
await page.waitForSelector('.modal.task-create-modal', { timeout: 10000 });
|
||||
|
||||
const taskItemTitle = `Test Task for Comment ${TS}`;
|
||||
await page.getByTestId('task-title-input').fill(taskItemTitle);
|
||||
await page.getByTestId('task-description-input').fill('Task created for comment testing');
|
||||
|
||||
// Select our project and wait for milestones to load
|
||||
await page.getByTestId('project-select').selectOption({ label: projectName });
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const milestoneSelect = page.getByTestId('milestone-select');
|
||||
await expect.poll(async () => {
|
||||
const options = await milestoneSelect.locator('option').allTextContents();
|
||||
return options.includes(milestoneName);
|
||||
}, { timeout: 10000 }).toBeTruthy();
|
||||
const msOptions = await milestoneSelect.locator('option').allTextContents();
|
||||
console.log('Milestone options:', msOptions);
|
||||
await milestoneSelect.selectOption({ label: milestoneName });
|
||||
|
||||
await page.getByTestId('task-type-select').selectOption('issue');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const createTaskResponsePromise = page.waitForResponse((response) => {
|
||||
return response.request().method() === 'POST' && /\/tasks(?:\?|$)/.test(response.url());
|
||||
});
|
||||
|
||||
await page.getByTestId('create-task-button').click();
|
||||
const createTaskResponse = await createTaskResponsePromise;
|
||||
expect(createTaskResponse.ok()).toBeTruthy();
|
||||
const createdTask = await createTaskResponse.json();
|
||||
console.log('Created task item response:', createdTask);
|
||||
|
||||
await page.waitForURL(`${BASE_URL}/tasks`, { timeout: 10000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('.tasks-table')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator(`.tasks-table tr:has-text("${taskItemTitle}")`)).toBeVisible({ timeout: 10000 });
|
||||
console.log('✅ Task item created and visible in list');
|
||||
|
||||
// ── Step 6: Open Task → Add Comment ────────────────────────────────
|
||||
console.log('💬 Opening task to add comment...');
|
||||
await page.click(`.tasks-table tr:has-text("${taskItemTitle}")`);
|
||||
await page.waitForURL(`${BASE_URL}/tasks/${createdTask.id}`, { timeout: 10000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify we're on task detail
|
||||
await page.waitForSelector(`h2:has-text("${taskItemTitle}")`, { timeout: 10000 });
|
||||
|
||||
const commentText = `Automated test comment ${TS}`;
|
||||
console.log(`Comment: ${commentText}`);
|
||||
await page.fill('textarea[placeholder="Add a comment..."]', commentText);
|
||||
await page.click('button:has-text("Submit comment")');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify comment appears
|
||||
const commentEl = page.locator(`.comment p:has-text("${commentText}")`);
|
||||
await expect(commentEl).toBeVisible({ timeout: 10000 });
|
||||
console.log('✅ Comment added and verified');
|
||||
|
||||
// ── Step 7: Logout (do NOT delete the project) ─────────────────────
|
||||
console.log('🚪 Logging out...');
|
||||
await page.click('button:has-text("Log out")');
|
||||
await page.waitForFunction(() => !localStorage.getItem('token'), { timeout: 10000 });
|
||||
await page.waitForSelector('button:has-text("Log in")', { timeout: 10000 });
|
||||
console.log('✅ Logged out');
|
||||
|
||||
console.log('🎉 All task & comment tests passed!');
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const WIZARD_URL = process.env.WIZARD_URL || 'http://127.0.0.1:18080';
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://127.0.0.1:8000';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://frontend:3000';
|
||||
|
||||
test.describe('Setup Wizard', () => {
|
||||
test('complete wizard flow', async ({ page }) => {
|
||||
// Go to frontend which should redirect to wizard
|
||||
await page.goto('/');
|
||||
test('frontend loads and shows main page after wizard configuration', async ({ page }) => {
|
||||
// Navigate to frontend - wizard should be configured by globalSetup
|
||||
await page.goto(FRONTEND_URL);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Step 0: Welcome - Click "Connect to Wizard"
|
||||
await expect(page.locator('h1')).toContainText('HarborForge Setup Wizard');
|
||||
await page.click('button:has-text("Connect to Wizard")');
|
||||
|
||||
// Wait for wizard health check - should proceed to step 1
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Step 1: Database - Click Next with defaults
|
||||
if (await page.locator('h2:has-text("Database configuration")').isVisible()) {
|
||||
await page.click('button:has-text("Next")');
|
||||
}
|
||||
|
||||
// Step 2: Admin - Fill in admin credentials
|
||||
await page.fill('input[placeholder="Set admin password"]', 'admin123');
|
||||
await page.fill('input[type="email"]', 'admin@test.com');
|
||||
await page.fill('input[value="Admin"]', 'Test Admin');
|
||||
await page.click('button:has-text("Next")');
|
||||
|
||||
// Step 3: Project - Configure backend and project
|
||||
await page.fill('input[placeholder="http://127.0.0.1:8000"]', BACKEND_URL);
|
||||
await page.fill('input[value="Default"]', 'Test Project');
|
||||
await page.fill('input[value="Default project"]', 'Test Project Description');
|
||||
await page.click('button:has-text("Finish setup")');
|
||||
|
||||
// Step 4: Complete
|
||||
await expect(page.locator('h2')).toContainText('Setup complete!');
|
||||
await expect(page.locator('code')).toContainText('docker compose restart');
|
||||
});
|
||||
|
||||
test('wizard health check', async ({ request }) => {
|
||||
const response = await request.get(`${WIZARD_URL}/health`);
|
||||
// Wizard might return 200 or 404 if not initialized
|
||||
expect([200, 404]).toContain(response.status());
|
||||
// Should now show main page (not wizard redirect)
|
||||
// After wizard config, frontend should load normally
|
||||
await expect(page.locator('h1')).toContainText('HarborForge', { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user