Files
tilbudgivern/tests/e2e-full-project-flow.spec.js

378 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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