- 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
324 lines
11 KiB
JavaScript
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}`);
|
|
});
|
|
}
|
|
}
|
|
});
|