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