378 lines
19 KiB
JavaScript
378 lines
19 KiB
JavaScript
// @ts-check
|
||
const { test, expect } = require('@playwright/test');
|
||
const path = require('path');
|
||
|
||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || 'https://tilbudsgiveren.alw.dk';
|
||
const ARTIFACTS_DIR = path.join(__dirname, 'artifacts');
|
||
|
||
const LOGIN_USERNAME = 'toemrer';
|
||
const LOGIN_PASSWORD = process.env.AUTH_PASSWORD;
|
||
|
||
const TEST_PROJECT_NAME = `E2E Test ${Date.now()}`;
|
||
const TEST_CUSTOMER_NAME = 'E2E Test Kunde';
|
||
const TEST_CUSTOMER_EMAIL = 'e2etest@example.com';
|
||
|
||
async function login(page, maxRetries = 3) {
|
||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||
console.log(`[LOGIN] Attempt ${attempt}/${maxRetries}...`);
|
||
await page.goto(BASE_URL + '/', { waitUntil: 'networkidle', timeout: 30000 });
|
||
await page.waitForTimeout(1000);
|
||
|
||
const isLogin = await page.getByRole('heading', { name: /Log ind/i }).isVisible({ timeout: 5000 }).catch(() => false);
|
||
const hasPassword = await page.locator('input[type="password"]').first().isVisible({ timeout: 3000 }).catch(() => false);
|
||
|
||
if (!isLogin && !hasPassword) {
|
||
console.log('[LOGIN] Already authenticated');
|
||
return;
|
||
}
|
||
|
||
await page.locator('input[type="text"]').first().fill(LOGIN_USERNAME);
|
||
await page.locator('input[type="password"]').first().fill(LOGIN_PASSWORD);
|
||
await page.waitForTimeout(300);
|
||
await page.getByRole('button', { name: /Log ind/i }).click();
|
||
await page.waitForTimeout(3000);
|
||
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
||
|
||
const stillOnLogin = await page.getByRole('heading', { name: /Log ind/i }).isVisible().catch(() => false);
|
||
if (!stillOnLogin) {
|
||
console.log('[LOGIN] Login successful');
|
||
return;
|
||
}
|
||
if (attempt < maxRetries) await page.waitForTimeout(2000);
|
||
}
|
||
throw new Error('Login failed');
|
||
}
|
||
|
||
test.describe('Fuld projekt flow fra start til slut', () => {
|
||
test.setTimeout(240000); // 4 minutes
|
||
|
||
test('Opret projekt → Geometri → Smart Pakke → AI Tilbudstekst', async ({ page }) => {
|
||
|
||
// ─── STEP 0: Login ───────────────────────────────────────────
|
||
console.log('[FLOW] Step 0: Login...');
|
||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await login(page);
|
||
console.log('[FLOW] ✅ Login OK');
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-00-logged-in.png') });
|
||
|
||
// ─── STEP 1: Click "Nyt Projekt" tab ─────────────────────────
|
||
console.log('[FLOW] Step 1: Click "Nyt Projekt" tab...');
|
||
|
||
// The app loads on ProjectFlow. Click "Nyt Projekt" toggle button
|
||
const nytProjektBtn = page.getByRole('button', { name: /Nyt Projekt/i });
|
||
await nytProjektBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||
if (await nytProjektBtn.isEnabled()) {
|
||
await nytProjektBtn.click();
|
||
}
|
||
await page.waitForTimeout(500);
|
||
console.log('[FLOW] ✅ Nyt Projekt tab clicked');
|
||
|
||
// ─── STEP 2: Fill Project Form ────────────────────────────────
|
||
console.log('[FLOW] Step 2: Fill project form...');
|
||
|
||
// FormField renders as input#field-{name}
|
||
const projektNavnInput = page.locator('#field-projectName');
|
||
await projektNavnInput.waitFor({ state: 'visible', timeout: 5000 });
|
||
await projektNavnInput.fill(TEST_PROJECT_NAME);
|
||
console.log(`[FLOW] Filled projektName: ${TEST_PROJECT_NAME}`);
|
||
|
||
// Customer name (below the CustomerSearch autocomplete)
|
||
const kundeNavnInput = page.locator('#field-customerName');
|
||
await kundeNavnInput.waitFor({ state: 'visible', timeout: 5000 });
|
||
await kundeNavnInput.fill(TEST_CUSTOMER_NAME);
|
||
console.log(`[FLOW] Filled customerName: ${TEST_CUSTOMER_NAME}`);
|
||
|
||
await page.getByRole('button', { name: /Vis valgfrie oplysninger/i }).click();
|
||
|
||
// Email
|
||
const emailInput = page.locator('#field-customerEmail');
|
||
await emailInput.waitFor({ state: 'visible', timeout: 5000 });
|
||
await emailInput.fill(TEST_CUSTOMER_EMAIL);
|
||
console.log(`[FLOW] Filled email: ${TEST_CUSTOMER_EMAIL}`);
|
||
|
||
// Description
|
||
const beskrivInput = page.locator('#field-projectDescription');
|
||
await beskrivInput.waitFor({ state: 'visible', timeout: 5000 });
|
||
await beskrivInput.fill('Fuld tagrenovering inkl. undertag og isolering. E2E test projekt.');
|
||
console.log('[FLOW] Filled description');
|
||
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-01-project-form-filled.png') });
|
||
|
||
// Submit
|
||
const gemProjektBtn = page.getByRole('button', { name: /Gem.*(Projekt|fortsæt)|fortsæt til geometri/i });
|
||
await gemProjektBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||
await gemProjektBtn.click();
|
||
console.log('[FLOW] Clicked "Gem Projekt"');
|
||
|
||
// Wait for project creation and navigation to step 2 (Geometri)
|
||
await page.waitForTimeout(4000);
|
||
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
||
|
||
const bodyAfterCreate = await page.locator('body').textContent();
|
||
const onGeometriStep = bodyAfterCreate.includes('Bredde') || bodyAfterCreate.includes('geometri') || bodyAfterCreate.includes('Avanceret Geometri');
|
||
console.log(`[FLOW] On Geometri step: ${onGeometriStep}`);
|
||
console.log(`[FLOW] Page contains "Bredde": ${bodyAfterCreate.includes('Bredde')}`);
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-02-after-project-create.png') });
|
||
|
||
if (!onGeometriStep) {
|
||
// Check for validation errors
|
||
const hasErrors = bodyAfterCreate.includes('påkrævet') || bodyAfterCreate.includes('Fejl') || bodyAfterCreate.includes('mangler');
|
||
console.log(`[FLOW] Validation errors present: ${hasErrors}`);
|
||
// Try to navigate to step 2 manually
|
||
const step2 = page.locator('.step').filter({ hasText: /Geometri/i });
|
||
const step2Visible = await step2.isVisible({ timeout: 3000 }).catch(() => false);
|
||
if (step2Visible) {
|
||
const step2Classes = await step2.getAttribute('class');
|
||
console.log(`[FLOW] Step 2 classes: ${step2Classes}`);
|
||
if (!step2Classes?.includes('disabled')) {
|
||
await step2.click();
|
||
await page.waitForTimeout(2000);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── STEP 3: Fill Geometry ────────────────────────────────────
|
||
console.log('[FLOW] Step 3: Fill geometry...');
|
||
await page.waitForTimeout(1000);
|
||
|
||
// Width input: label "Bredde (m):" with placeholder "10"
|
||
// These inputs don't have id/name, so use label → adjacent input
|
||
const breddeLabel = page.locator('label').filter({ hasText: /^Bredde \(m\):?$/ });
|
||
const breddeVisible = await breddeLabel.isVisible({ timeout: 5000 }).catch(() => false);
|
||
|
||
if (breddeVisible) {
|
||
// Get the input in the same form-group div
|
||
const breddeInput = breddeLabel.locator('xpath=following-sibling::input[@type="number"]');
|
||
await breddeInput.fill('10');
|
||
console.log('[FLOW] Filled Bredde: 10');
|
||
} else {
|
||
// Fallback: use placeholder
|
||
const widthByPlaceholder = page.locator('input[placeholder="10"]').first();
|
||
if (await widthByPlaceholder.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||
await widthByPlaceholder.fill('10');
|
||
console.log('[FLOW] Filled width (placeholder fallback): 10');
|
||
}
|
||
}
|
||
|
||
// Length input: label "Længde (m):"
|
||
const laengdeLabel = page.locator('label').filter({ hasText: /^Længde \(m\):?$/ });
|
||
const laengdeVisible = await laengdeLabel.isVisible({ timeout: 3000 }).catch(() => false);
|
||
|
||
if (laengdeVisible) {
|
||
const laengdeInput = laengdeLabel.locator('xpath=following-sibling::input[@type="number"]');
|
||
await laengdeInput.fill('12');
|
||
console.log('[FLOW] Filled Længde: 12');
|
||
} else {
|
||
const lengthByPlaceholder = page.locator('input[placeholder="12.0"]').first();
|
||
if (await lengthByPlaceholder.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||
await lengthByPlaceholder.fill('12');
|
||
console.log('[FLOW] Filled length (placeholder fallback): 12');
|
||
}
|
||
}
|
||
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-03-geometry-filled.png') });
|
||
|
||
// Click "🔄 Gem Nu" to calculate
|
||
const gemNuBtn = page.getByRole('button', { name: /Gem Nu/i });
|
||
const hasGemNu = await gemNuBtn.isVisible({ timeout: 5000 }).catch(() => false);
|
||
if (hasGemNu) {
|
||
console.log('[FLOW] Clicking "Gem Nu"...');
|
||
await gemNuBtn.click();
|
||
await page.waitForTimeout(6000);
|
||
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
|
||
}
|
||
|
||
const bodyAfterCalc = await page.locator('body').textContent();
|
||
const hasM2 = bodyAfterCalc.includes('m²') || bodyAfterCalc.includes('m2');
|
||
const hasBeregnet = bodyAfterCalc.toLowerCase().includes('beregnet') || bodyAfterCalc.toLowerCase().includes('succesfuldt');
|
||
console.log(`[FLOW] After calc - has m²: ${hasM2}, has "beregnet": ${hasBeregnet}`);
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-04-geometry-calculated.png') });
|
||
|
||
// Click "Fortsæt til Smart Pakke"
|
||
const fortsetBtn = page.getByRole('button', { name: /Fortsæt til Smart Pakke/i });
|
||
const hasFortset = await fortsetBtn.isVisible({ timeout: 8000 }).catch(() => false);
|
||
const fortsetEnabled = hasFortset && await fortsetBtn.isEnabled().catch(() => false);
|
||
console.log(`[FLOW] "Fortsæt til Smart Pakke" visible: ${hasFortset}, enabled: ${fortsetEnabled}`);
|
||
|
||
if (hasFortset && fortsetEnabled) {
|
||
console.log('[FLOW] Clicking "Fortsæt til Smart Pakke"...');
|
||
await fortsetBtn.click();
|
||
await page.waitForTimeout(3000);
|
||
} else {
|
||
// Try "Spring over tagberegning" shortcut
|
||
const springOverBtn = page.getByRole('button', { name: /Spring over tagberegning/i });
|
||
const hasSpringOver = await springOverBtn.isVisible({ timeout: 3000 }).catch(() => false);
|
||
if (hasSpringOver) {
|
||
console.log('[FLOW] Using "Spring over tagberegning" shortcut...');
|
||
await springOverBtn.click();
|
||
await page.waitForTimeout(2000);
|
||
} else {
|
||
// Direct step click
|
||
const step3 = page.locator('.step').filter({ hasText: /Smart Pakke/i });
|
||
if (await step3.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||
const s3Class = await step3.getAttribute('class');
|
||
if (!s3Class?.includes('disabled')) {
|
||
await step3.click();
|
||
await page.waitForTimeout(2000);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const bodyAfterNav = await page.locator('body').textContent();
|
||
const onSmartPakke = bodyAfterNav.includes('Vælg Smart Pakke') || bodyAfterNav.includes('package-option') || bodyAfterNav.includes('Materialer');
|
||
console.log(`[FLOW] On Smart Pakke step: ${onSmartPakke}`);
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-05-smart-pakke-step.png') });
|
||
|
||
// ─── STEP 4: Smart Package Selection ─────────────────────────
|
||
console.log('[FLOW] Step 4: Select smart package...');
|
||
await page.waitForTimeout(2000);
|
||
|
||
const databasePackageSelect = page.locator('#database-smart-package');
|
||
const hasDatabasePackages = await databasePackageSelect.isVisible({ timeout: 5000 }).catch(() => false);
|
||
if (hasDatabasePackages) {
|
||
const packageSearch = page.getByRole('searchbox', { name: /Søg i gemte Smart Pakker/i });
|
||
await packageSearch.fill('Montage af taglægter');
|
||
await expect(page.getByText(/Viser 1 af \d+ pakker/i)).toBeVisible();
|
||
|
||
const materialSave = page.waitForResponse(response => (
|
||
response.url().includes('/materials/bulk') && response.request().method() === 'POST'
|
||
));
|
||
await databasePackageSelect.selectOption('265');
|
||
expect((await materialSave).ok()).toBeTruthy();
|
||
await expect(page.getByRole('textbox', { name: 'Materialenavn 2' })).toHaveValue('T1-taglægter');
|
||
} else {
|
||
// Click "Spring over - Tilføj manuelt"
|
||
const manualBtn = page.getByRole('button', { name: /Spring over.*manuelt|Tilføj manuelt/i });
|
||
if (await manualBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||
await manualBtn.click();
|
||
await page.waitForTimeout(1000);
|
||
console.log('[FLOW] Using manual package mode');
|
||
}
|
||
}
|
||
|
||
const bodyAfterPkg = await page.locator('body').textContent();
|
||
const matHeader = bodyAfterPkg.match(/(\d+) materialer/)?.[1];
|
||
console.log(`[FLOW] Materials after package select: ${matHeader ?? 'unknown'}`);
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-06-package-selected.png') });
|
||
|
||
// Add labor task: "➕ Tilføj opgave manuelt"
|
||
const addTaskBtn = page.getByRole('button', { name: /Tilføj opgave manuelt/i });
|
||
const hasAddTask = await addTaskBtn.isVisible({ timeout: 5000 }).catch(() => false);
|
||
if (hasAddTask) {
|
||
console.log('[FLOW] Adding labor task...');
|
||
await addTaskBtn.click();
|
||
await page.waitForTimeout(1000);
|
||
}
|
||
|
||
const bodyAfterTask = await page.locator('body').textContent();
|
||
const taskHeader = bodyAfterTask.match(/(\d+) opgaver/)?.[1];
|
||
console.log(`[FLOW] Tasks after adding: ${taskHeader ?? 'unknown'}`);
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-07-task-added.png') });
|
||
|
||
// Click "Gem Smart Pakke"
|
||
const gemSmartPakkeBtn = page.getByRole('button', { name: /Gem Smart Pakke/i });
|
||
if (await gemSmartPakkeBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||
console.log('[FLOW] Clicking "Gem Smart Pakke"...');
|
||
await gemSmartPakkeBtn.click();
|
||
await page.waitForTimeout(1500);
|
||
}
|
||
|
||
// Click "Næste: Final Review →"
|
||
const naesteFinalBtn = page.getByRole('button', { name: /Næste.*Final Review/i });
|
||
const hasNaeste = await naesteFinalBtn.isVisible({ timeout: 5000 }).catch(() => false);
|
||
const naestEnabled = hasNaeste && await naesteFinalBtn.isEnabled().catch(() => false);
|
||
console.log(`[FLOW] "Næste: Final Review" visible: ${hasNaeste}, enabled: ${naestEnabled}`);
|
||
|
||
if (hasNaeste && naestEnabled) {
|
||
console.log('[FLOW] Clicking "Næste: Final Review →"...');
|
||
await naesteFinalBtn.click();
|
||
await page.waitForTimeout(3000);
|
||
} else {
|
||
// Try step 4 directly
|
||
const step4 = page.locator('.step').filter({ hasText: /Final Review/i });
|
||
if (await step4.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||
const s4Class = await step4.getAttribute('class');
|
||
console.log(`[FLOW] Step 4 classes: ${s4Class}`);
|
||
if (!s4Class?.includes('disabled')) {
|
||
await step4.click();
|
||
await page.waitForTimeout(2000);
|
||
}
|
||
}
|
||
}
|
||
|
||
const bodyAfterFinalNav = await page.locator('body').textContent();
|
||
const onFinalReview = bodyAfterFinalNav.includes('Tilbudstekst') || bodyAfterFinalNav.includes('Generer') || bodyAfterFinalNav.includes('Ordrestyring');
|
||
console.log(`[FLOW] On Final Review: ${onFinalReview}`);
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-08-final-review.png') });
|
||
|
||
// ─── STEP 5: Generate AI Quote ────────────────────────────────
|
||
console.log('[FLOW] Step 5: Generate AI quote...');
|
||
await page.waitForTimeout(1000);
|
||
|
||
// Select AI radio if not already selected
|
||
const aiRadio = page.locator('input[type="radio"][value="ai"]');
|
||
if (await aiRadio.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||
if (!await aiRadio.isChecked().catch(() => false)) {
|
||
await aiRadio.click();
|
||
await page.waitForTimeout(500);
|
||
console.log('[FLOW] Selected AI quote type');
|
||
}
|
||
}
|
||
|
||
// Click generate button
|
||
const genererBtn = page.getByRole('button', { name: /Generer.*Tilbudstekst|Generer AI/i });
|
||
const hasGenerer = await genererBtn.isVisible({ timeout: 5000 }).catch(() => false);
|
||
const genererEnabled = hasGenerer && await genererBtn.isEnabled().catch(() => false);
|
||
console.log(`[FLOW] "Generer" button visible: ${hasGenerer}, enabled: ${genererEnabled}`);
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-09-before-generate.png') });
|
||
|
||
if (hasGenerer && genererEnabled) {
|
||
console.log('[FLOW] Clicking "Generer AI Tilbudstekst"...');
|
||
await genererBtn.click();
|
||
|
||
// Wait for generation (max 4 minutes - uses polling pattern now)
|
||
console.log('[FLOW] Waiting for AI generation (async polling, max 4 min)...');
|
||
let elapsed = 0;
|
||
let stillGenerating = true;
|
||
while (stillGenerating && elapsed < 240000) {
|
||
await page.waitForTimeout(5000);
|
||
elapsed += 5000;
|
||
const spinnerVisible = await page.getByRole('button', { name: /Genererer/i }).isVisible({ timeout: 1000 }).catch(() => false);
|
||
stillGenerating = spinnerVisible;
|
||
if (elapsed % 15000 === 0) console.log(`[FLOW] Still generating... ${elapsed / 1000}s`);
|
||
}
|
||
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-10-after-generate.png') });
|
||
|
||
const quoteTextarea = page.locator('.quote-textarea');
|
||
const hasQuote = await quoteTextarea.isVisible({ timeout: 5000 }).catch(() => false);
|
||
if (hasQuote) {
|
||
const quoteContent = await quoteTextarea.inputValue();
|
||
console.log(`[FLOW] ✅ AI QUOTE GENERATED! Length: ${quoteContent.length} chars`);
|
||
console.log(`[FLOW] Quote preview: "${quoteContent.substring(0, 300)}"`);
|
||
} else {
|
||
const bodyFinal = await page.locator('body').textContent();
|
||
console.log(`[FLOW] ⚠️ Quote textarea not found. Body length: ${bodyFinal.length}`);
|
||
}
|
||
} else {
|
||
console.log('[FLOW] ❌ SKIPPED: Generate button not available');
|
||
const debugText = await page.locator('body').textContent();
|
||
console.log(`[FLOW] Page content (first 500 chars): ${debugText.substring(0, 500)}`);
|
||
}
|
||
|
||
await page.screenshot({ path: path.join(ARTIFACTS_DIR, 'flow-11-final-state.png'), fullPage: true });
|
||
|
||
// ─── Summary ──────────────────────────────────────────────────
|
||
const finalText = await page.locator('body').textContent();
|
||
console.log('\n[FLOW] ════════════════ SUMMARY ════════════════');
|
||
console.log(`[FLOW] Project name: ${TEST_PROJECT_NAME}`);
|
||
console.log(`[FLOW] Final Review reached: ${finalText.includes('Tilbudstekst') || finalText.includes('Generer')}`);
|
||
console.log(`[FLOW] Quote generated: ${finalText.includes('quote-textarea') || finalText.length > 9000}`);
|
||
console.log('[FLOW] ════════════════════════════════════════\n');
|
||
|
||
// Hard assertion: verify project was created (page advanced past step 1)
|
||
expect(onFinalReview || onSmartPakke || onGeometriStep).toBe(true);
|
||
});
|
||
});
|