Files
tilbudgivern/tests/carpenter-agent-openai.spec.js
alexpolo1 83a5af07d8 fix: strengthen tilbudsgivern flow validation and automation
- tighten carpenter AI e2e success criteria to require real ordrestyring submission

- improve login field usability (labels/placeholders/autocomplete)

- add final review gating for missing core quote data

- normalize window task naming in carpenter API fixtures
2026-05-13 09:25:51 +00:00

324 lines
11 KiB
JavaScript

const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
/**
* 🔨 CARPENTER AGENT - OpenAI Browser Automation
*
* An intelligent Playwright test that uses Codex CLI to:
* - Understand the UI dynamically
* - Make intelligent decisions about interactions
* - Fill forms naturally based on carpenter persona
* - Generate realistic quotes through the web interface
* - Validate results intelligently
*/
const BASE_URL = process.env.BASE_URL || 'http://localhost:4032';
const CODEX_MODEL = process.env.CODEX_MODEL || 'gpt-5.4-mini';
const TEST_USERNAME = process.env.TEST_USERNAME || 'admin';
const TEST_PASSWORD = process.env.TEST_PASSWORD || 'admin123';
function runCodexPrompt(prompt) {
const outputFile = path.join(
os.tmpdir(),
`codex-carpenter-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`
);
try {
execFileSync(
'codex',
[
'exec',
'-m',
CODEX_MODEL,
'--color',
'never',
'--output-last-message',
outputFile,
prompt
],
{
cwd: path.resolve(__dirname, '..'),
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024
}
);
return fs.readFileSync(outputFile, 'utf8').trim();
} finally {
if (fs.existsSync(outputFile)) {
fs.unlinkSync(outputFile);
}
}
}
// Carpenter personas
const CARPENTERS = {
lars: {
name: 'Lars Nielsen',
specialty: 'Tag og terrasser',
approach: 'Jeg arbejder metodisk og fokuserer på præcisision. Jeg har høj kvalitetskontrol.',
projects: [
'En 40m² tagflade med nogle lække steder der skal udbedres',
'Ny terrasse på 24m² i løst træ ved siden af huset'
]
},
jannick: {
name: 'Jannick Andersen',
specialty: 'Indendørs renovering',
approach: 'Jeg er kreativ og løsningsorienteret. Jeg lytter nøje til kunders ønsker.',
projects: [
'Nyt parketgulv på 45m² i stue og gang',
'Stor garderobe med skydedøre i soveværelset'
]
},
alexander: {
name: 'Alexander Ørneby Andersen',
specialty: 'Moderne bygge- og renovering',
approach: 'Jeg holder mig ajour med nye teknologier og bæredygtige materialer.',
projects: [
'5 nye dørinstallationer til åbent køkkenkoncept',
'Håndværkerspil på 12 vinduer'
]
}
};
/**
* Get page state and structure from UI
*/
async function capturePageState(page) {
const state = await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('button'))
.map(b => ({ text: b.textContent.trim(), visible: b.offsetHeight > 0 }))
.filter(b => b.text.length > 0 && b.text.length < 50);
const inputs = Array.from(document.querySelectorAll('input, textarea'))
.map(i => ({
type: i.type || i.tagName,
placeholder: i.placeholder,
visible: i.offsetHeight > 0
}))
.filter(i => i.visible);
const headings = Array.from(document.querySelectorAll('h1, h2, h3'))
.map(h => h.textContent.trim())
.filter(h => h.length > 0);
return {
url: window.location.href,
title: document.title,
headings,
buttons: buttons.slice(0, 10),
inputs: inputs.slice(0, 5),
hasForm: !!document.querySelector('form')
};
});
return state;
}
/**
* Ask Codex what to do next based on page state and carpenter persona
*/
async function decideNextAction(carpenter, pageState, context = '') {
const prompt = `Du er ${carpenter.name}, en erfaren tømrer specialiseret i ${carpenter.specialty}.
${carpenter.approach}
Nuværende side:
${JSON.stringify(pageState, null, 2)}
${context ? `Context: ${context}` : ''}
Baseret på siden, hvad skal jeg gøre næste gang? Svar KORT og DIREKTE med PRÆCIS:
1. Hvilken knap/input jeg skal klikke eller udfylde
2. Hvad jeg skal skrive (hvis input)
Svar format:
ACTION: [click button "..."] eller [fill input "..." with "..."]
VIGTIG: Svar ALTID på dansk med præcise instruktioner baseret på hvad du ser på siden.`;
return runCodexPrompt(prompt);
}
/**
* Execute action decided by AI
*/
async function executeAction(page, action) {
console.log(` → Executing: ${action}`);
// Parse action
const clickMatch = action.match(/click button "([^"]+)"/i) || action.match(/click "([^"]+)"/i);
const clickInputMatch = action.match(/click input "([^"]+)"/i) || action.match(/click field "([^"]+)"/i);
const fillMatch = action.match(/fill input "([^"]+)" with "([^"]*)"/i) || action.match(/fill "([^"]+)" with "([^"]*)"/i);
if (clickMatch) {
const buttonText = clickMatch[1];
const button = await page.locator(`button:has-text("${buttonText}"), a:has-text("${buttonText}")`).first();
if (await button.isVisible({ timeout: 3000 }).catch(() => false)) {
await button.click();
await page.waitForTimeout(1000);
return true;
} else {
console.warn(` ⚠️ Button "${buttonText}" not found`);
return false;
}
} else if (clickInputMatch) {
const fieldText = clickInputMatch[1];
const input = await page.locator(
`input[placeholder*="${fieldText}" i], textarea[placeholder*="${fieldText}" i], input[name*="${fieldText}" i], input[id*="${fieldText}" i], textarea[id*="${fieldText}" i], label:has-text("${fieldText}") + input, label:has-text("${fieldText}") + textarea`
).first();
if (await input.isVisible({ timeout: 3000 }).catch(() => false)) {
await input.click();
await page.waitForTimeout(500);
return true;
}
console.warn(` ⚠️ Clickable field "${fieldText}" not found`);
return false;
} else if (fillMatch) {
const fieldText = fillMatch[1];
const value = fillMatch[2];
const input = await page.locator(
`input[placeholder*="${fieldText}" i], textarea[placeholder*="${fieldText}" i], input[name*="${fieldText}" i], input[id*="${fieldText}" i], textarea[id*="${fieldText}" i], input[type="text"], textarea`
).first();
if (await input.isVisible({ timeout: 3000 }).catch(() => false)) {
await input.fill(value);
console.log(` ✅ Filled: ${fieldText} = ${value}`);
await page.waitForTimeout(500);
return true;
} else {
console.warn(` ⚠️ Field "${fieldText}" not found`);
return false;
}
}
return false;
}
test.describe('🔨 CARPENTER AGENT - OpenAI Powered Browser Automation', () => {
for (const [carpenterId, carpenter] of Object.entries(CARPENTERS)) {
for (let projectIdx = 0; projectIdx < carpenter.projects.length; projectIdx++) {
const project = carpenter.projects[projectIdx];
const testName = `${carpenter.name}: ${project.substring(0, 40)}...`;
test(testName, async ({ page }) => {
test.setTimeout(120000);
console.log(`\n${'='.repeat(80)}`);
console.log(`🔨 CARPENTER: ${carpenter.name}`);
console.log(`📋 PROJECT: ${project}`);
console.log(`${'='.repeat(80)}\n`);
// Navigate to site
console.log('🌐 Navigating to application...');
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Handle login deterministically
const loginBtn = page.locator('button:has-text("Log ind")');
if (await loginBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
console.log('🔐 Logging in...');
await page.fill('input[name="username"], input#username, input[type="text"]', TEST_USERNAME);
await page.fill('input[name="password"], input#password, input[type="password"]', TEST_PASSWORD);
await loginBtn.click();
await page.waitForTimeout(2000);
}
const stillOnLogin = await page.locator('h2:has-text("Log ind"), form input[type="password"]').first().isVisible({ timeout: 1000 }).catch(() => false);
if (stillOnLogin) {
throw new Error('Login lykkedes ikke. Tjek TEST_USERNAME/TEST_PASSWORD eller auth backend.');
}
// AI-guided navigation (max 10 steps)
let stepCount = 0;
const maxSteps = 10;
let currentContext = `Carpenter wants to create quote for: ${project}`;
let quoteGenerated = false;
let strictQuoteSuccess = false;
while (stepCount < maxSteps && !quoteGenerated) {
stepCount++;
console.log(`\n📍 Step ${stepCount}:`);
// Capture current page state
const pageState = await capturePageState(page);
console.log(` Current page: ${pageState.title}`);
if ((pageState.title || '').toLowerCase().includes('error')) {
throw new Error(`Applikationen er i fejltilstand (titel: "${pageState.title}") før tilbudsflow kunne startes`);
}
// Ask AI what to do
console.log(' 🤖 AI thinking...');
const action = await decideNextAction(carpenter, pageState, currentContext);
console.log(` AI Decision: ${action}`);
// Prevent AI from getting stuck re-filling login fields.
if (/log ind|brugernavn|password|indtast password|indtast brugernavn/i.test(action)) {
throw new Error('AI forsøger login-handlinger efter login-fasen. Stopper for at undgå falske loops.');
}
// Execute action
const success = await executeAction(page, action);
if (!success) {
console.log(' ⚠️ Action failed, trying alternative...');
}
// Strict success criteria: actual submission feedback must exist
const successBanner = page.locator('.submit-result.success:has-text("Tilbud sendt til ordrestyring")');
const submitButton = page.locator('button:has-text("Send til Ordrestyring")');
const hasSuccessBanner = await successBanner.isVisible({ timeout: 500 }).catch(() => false);
const submitVisible = await submitButton.isVisible({ timeout: 500 }).catch(() => false);
if (hasSuccessBanner) {
strictQuoteSuccess = true;
quoteGenerated = true;
console.log('\n✅ Tilbud faktisk oprettet og sendt til Ordrestyring.');
} else if (!submitVisible && stepCount >= maxSteps) {
// Fail-safe for flows that never reach actionable submit state
quoteGenerated = false;
}
// Update context for next step
currentContext = `User clicked/filled ${action}. Looking for quote form elements.`;
}
// Take final screenshot
const filename = `${carpenterId}-${projectIdx}-quote.png`;
await page.screenshot({
path: `test-results/carpenter-openai-${filename}`,
fullPage: true
});
// Save test data
const testData = {
timestamp: new Date().toISOString(),
carpenter: {
id: carpenterId,
name: carpenter.name,
specialty: carpenter.specialty
},
project,
steps: stepCount,
quoteGenerated,
screenshot: filename,
url: page.url()
};
const dataFile = `test-results/carpenter-openai-${carpenterId}-${projectIdx}.json`;
fs.writeFileSync(dataFile, JSON.stringify(testData, null, 2));
expect(strictQuoteSuccess, 'Tilbud blev ikke faktisk sendt til Ordrestyring').toBe(true);
console.log(`\n✅ Test completed - Data saved to ${dataFile}`);
});
}
}
});