From e4bb705d051b8efda0e14596fa7a355126efefa6 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 20:52:05 +0200 Subject: [PATCH] test(ui): accept the call-feedback fixes with measured browser checks One check per plan item, measured in the running page instead of described: canvas identity and fit count across selection and Expand, camera movement under the wheel, root marker position, toolbar height, path and call-order stepping, element under the Send button, the prompt sent to the browser model, the caller list against query_graph, cached explanations, Overview labels inside a large area, System structure, Behavior starts and grouped endpoints. Movements are also cut into frame strips from a recorded video. Signed-off-by: Bernhard Jackiewicz --- graph-ui/tools/call-feedback-acceptance.mjs | 632 ++++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100644 graph-ui/tools/call-feedback-acceptance.mjs diff --git a/graph-ui/tools/call-feedback-acceptance.mjs b/graph-ui/tools/call-feedback-acceptance.mjs new file mode 100644 index 000000000..22414e7d3 --- /dev/null +++ b/graph-ui/tools/call-feedback-acceptance.mjs @@ -0,0 +1,632 @@ +#!/usr/bin/env node +/* + * Abnahme der Fixes zum Review-Call vom 02.10.2026. + * + * node tools/call-feedback-acceptance.mjs --origin http://127.0.0.1:4350 \ + * --out /tmp/acceptance --profile /tmp/profile [--project django-demo] [--control cbm] \ + * [--only galaxy,chat,architecture] [--no-model] + * + * Wo tools/call-feedback-capture.mjs nur aufzeichnet, entscheidet dieses Skript: + * jede Pruefung steht fuer genau einen Punkt aus + * docs/development/pr-2068-call-feedback-plan.md, misst ihn in der laufenden + * Seite und schreibt bestanden oder nicht bestanden mit dem gemessenen Wert + * nach report.json. Daneben liegen die Bilder jeder Pruefung und ein Video, + * aus dem fuer die Bewegungen (Expandieren, Zoomen, Pfad durchschalten) + * Bildstreifen mit sechs Bildern pro Sekunde entstehen. Ein Pruefer, der kein + * Video ansehen kann, sieht so auch die Uebergaenge und nicht nur das Ende. + * + * Es startet keinen Server. Es braucht einen laufenden Server mit UI und ein + * Browserprofil, in dem das lokale Modell schon liegt (sonst --no-model). + */ + +import { chromium } from 'playwright'; +import { execFile } from 'node:child_process'; +import { mkdir, readdir, rename, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +const run = promisify(execFile); +const argv = process.argv.slice(2); +const arg = (name, fallback) => { + const index = argv.indexOf(`--${name}`); + if (index < 0) return fallback; + const value = argv[index + 1]; + return value === undefined || value.startsWith('--') ? true : value; +}; + +const ORIGIN = String(arg('origin', 'http://127.0.0.1:4350')).replace(/\/$/, ''); +const OUT = resolve(String(arg('out', 'verification/call-feedback-2026-10-02/abnahme'))); +const PROFILE = resolve(String(arg('profile', join(OUT, '..', 'profile')))); +const PROJECT = String(arg('project', 'django-demo')); +const CONTROL = String(arg('control', 'cbm')); +const ONLY = String(arg('only', 'galaxy,chat,architecture')).split(','); +const MODEL = arg('no-model', false) !== true; +const VIEWPORT = { width: 1600, height: 1000 }; +// Ein vollstaendiges ffmpeg zuerst: das von Playwright mitgelieferte kennt nur die Filter fuer die Aufnahme. +const FFMPEG = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', join(homedir(), 'Library/Caches/ms-playwright/ffmpeg-1011/ffmpeg-mac')].find((path) => existsSync(path)); + +const wait = (ms) => new Promise((done) => setTimeout(done, ms)); +const started = Date.now(); +const since = () => (Date.now() - started) / 1000; + +/* ------------------------------------------------------------------ */ +/* Server */ + +let rpcId = 1; +async function tool(name, args) { + const res = await fetch(`${ORIGIN}/rpc`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: rpcId++, method: 'tools/call', params: { name, arguments: args } }), + }); + const body = await res.json(); + const text = body?.result?.content?.[0]?.text ?? ''; + try { return JSON.parse(text); } catch { return { raw: text }; } +} + +/** Die eingehenden Kanten eines Namens, so wie der Index sie kennt. */ +async function incoming(name) { + const answer = await tool('query_graph', { project: PROJECT, query: `MATCH (a)-[r]->(b) WHERE b.name = '${name}' RETURN a.name AS source, type(r) AS type ORDER BY type, source LIMIT 500` }); + const rows = String(answer.raw ?? '').split('\n').map((line) => line.trim()).filter((line) => /^"?[\w.]+"?\s+"?[A-Z_]+"?$/.test(line)); + return rows.map((line) => { const [source, type] = line.replace(/"/g, '').split(/\s+/); return { source, type }; }); +} + +/* ------------------------------------------------------------------ */ +/* In jede Seite */ + +function pageProbe() { + try { localStorage.setItem('cbm.workspace.setup', 'done'); } catch { /* ohne Speicher geht es auch */ } + const mountCursor = () => { + if (document.getElementById('probe-cursor')) return; + const dot = document.createElement('div'); + dot.id = 'probe-cursor'; + dot.style.cssText = 'position:fixed;left:-100px;top:-100px;width:22px;height:22px;margin:-11px 0 0 -11px;border:2px solid #ff3b6b;' + + 'border-radius:50%;box-shadow:0 0 0 2px rgba(0,0,0,.6);pointer-events:none;z-index:2147483647;'; + document.documentElement.appendChild(dot); + document.addEventListener('mousemove', (event) => { dot.style.left = `${event.clientX}px`; dot.style.top = `${event.clientY}px`; }, true); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountCursor); else mountCursor(); + window.__probeErrors = []; + window.addEventListener('error', (event) => window.__probeErrors.push(String(event.message))); + window.__probeCanvasSeq = 0; + window.__probeCanvasId = () => { + const canvas = document.querySelector('.atlas-galaxy canvas'); + if (!canvas) return null; + if (!canvas.dataset.probeId) canvas.dataset.probeId = String(++window.__probeCanvasSeq); + return canvas.dataset.probeId; + }; + // Alles, was an den Modell-Worker geht, auch ueber spaeter angelegte Worker. + window.__probeWorker = []; + const NativeWorker = window.Worker; + window.Worker = class ProbeWorker extends NativeWorker { + constructor(url, options) { + super(url, options); + this.__probeSrc = String(url); + } + postMessage(message, transfer) { + try { + if (message && typeof message === 'object' && Array.isArray(message.messages)) { + window.__probeWorker.push({ t: performance.now(), kind: message.kind, profile: message.options?.generationProfile ?? message.generationProfile ?? '', messages: message.messages }); + } + } catch { /* nur Beobachtung */ } + return super.postMessage(message, transfer); + } + }; + window.__probeTimeline = []; + setInterval(() => { + const fit = globalThis.__atlasGalaxyFit?.measure?.(); + const galaxy = globalThis.__atlasGalaxy; + window.__probeTimeline.push({ + t: Math.round(performance.now()), canvas: window.__probeCanvasId?.() ?? null, + camera: fit ? fit.camera.position.map((v) => Number(v.toFixed(2))) : null, + fits: galaxy?.fits ?? null, nodes: galaxy?.nodes ?? null, + }); + if (window.__probeTimeline.length > 8000) window.__probeTimeline.splice(0, 2000); + }, 100); +} + +/* ------------------------------------------------------------------ */ +/* Ergebnis */ + +const checks = []; +const marks = []; +let shotIndex = 0; + +function check(id, title, pass, measured, images = []) { + checks.push({ id, title, pass: Boolean(pass), measured, images }); + console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${title} ${typeof measured === 'string' ? measured : JSON.stringify(measured)}`); +} + +async function shot(page, label) { + shotIndex += 1; + const file = `${String(shotIndex).padStart(3, '0')}-${label}.png`; + await page.screenshot({ path: join(OUT, 'bilder', file) }); + return file; +} + +/** Eine Bewegung, die als Bildstreifen aus dem Video kommen soll. */ +function mark(label, from, to) { marks.push({ label, from, to }); } + +async function state(page) { + return page.evaluate(() => { + const fit = globalThis.__atlasGalaxyFit?.measure?.(); + const galaxy = globalThis.__atlasGalaxy; + return { + canvas: window.__probeCanvasId?.() ?? null, + fits: galaxy?.fits ?? null, nodes: galaxy?.nodes ?? null, drawnEdges: galaxy?.drawnEdges ?? null, + outside: fit?.outside ?? null, inside: fit?.inside ?? null, camera: fit?.camera ?? null, + viewport: fit?.viewport ?? null, + toolbar: document.querySelector('.atlas-graph-exploration')?.textContent?.replace(/\s+/g, ' ').trim() ?? '', + }; + }); +} + +async function open(page, project, workspace) { + await page.goto(`${ORIGIN}/?project=${encodeURIComponent(project)}&workspace=${workspace}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.atlas-shell', { timeout: 30000 }); + await wait(1500); +} + +async function galaxyReady(page) { + await page.waitForFunction(() => (globalThis.__atlasGalaxy?.nodes ?? 0) > 0 && globalThis.__atlasGalaxyFit !== undefined, null, { timeout: 60000 }); + await wait(1200); +} + +async function select(page, name) { + const input = page.getByRole('searchbox', { name: 'Find a graph node' }).first(); + await input.click(); + await input.fill(''); + await input.type(name, { delay: 25 }); + const choice = page.locator('.atlas-galaxy-search-results button', { has: page.locator('strong', { hasText: new RegExp(`^${name}$`) }) }).first(); + await choice.waitFor({ timeout: 20000 }); + await choice.hover(); + await choice.click(); +} + +/** Bis die Statuszeile einen fertigen Ausschnitt meldet. */ +async function scopeSettled(page, timeout = 20000) { + await page.waitForFunction(() => { + const text = document.querySelector('.atlas-graph-exploration')?.textContent ?? ''; + return /\d+ nodes? · \d+ edges?/.test(text) && !/Loading relationships/.test(text); + }, null, { timeout }).catch(() => {}); + await wait(1500); +} + +const timelineSince = (page, t0) => page.evaluate((from) => window.__probeTimeline.filter((row) => row.t >= from), t0); +const now = (page) => page.evaluate(() => Math.round(performance.now())); +const intersects = (a, b) => Boolean(a && b) && !(a.x >= b.x + b.width || b.x >= a.x + a.width || a.y >= b.y + b.height || b.y >= a.y + a.height); + +/* ------------------------------------------------------------------ */ +/* Galaxie */ + +async function galaxyChecks(page) { + await open(page, PROJECT, 'galaxy'); + await galaxyReady(page); + const before = await state(page); + const images = [await shot(page, 'galaxie-ganzer-graph')]; + const t0 = await now(page); + const v0 = since(); + await select(page, 'JSONBAgg'); + images.push(await shot(page, 'jsonbagg-sofort')); + const blankFrames = []; + for (let i = 0; i < 8; i += 1) { + blankFrames.push(await page.evaluate(() => globalThis.__atlasGalaxyFit?.measure?.().nodes ?? 0)); + await wait(40); + } + await wait(250); images.push(await shot(page, 'jsonbagg-250ms')); + await wait(750); images.push(await shot(page, 'jsonbagg-1s')); + await scopeSettled(page); + images.push(await shot(page, 'jsonbagg-fertig')); + const selected = await state(page); + mark('auswahl-jsonbagg', v0, since()); + + // G2: eine Ebene mit Kanten, kein isolierter Knoten, und weniger als 1 geht nicht. + const minus = page.getByRole('button', { name: 'Remove graph layer' }); + const minusDisabled = await minus.isDisabled().catch(() => null); + const layerText = await page.locator('.atlas-graph-exploration').first().evaluate((bar) => [...bar.querySelectorAll('span, output, strong, small')].map((el) => el.textContent?.trim() ?? '').find((text) => /^\d+ layers?$/.test(text)) ?? '').catch(() => ''); + check('G2', 'Auswahl oeffnet eine Ebene mit Kanten', /^1 layer$/.test(layerText) && (selected.drawnEdges ?? 0) > 0 && minusDisabled === true, + { layerText, drawnEdges: selected.drawnEdges, minusDisabled }, images.slice(-1)); + + // G1: dieselbe Zeichenflaeche, Expand passt nicht neu ein. + const expand = page.getByRole('button', { name: 'Expand +1' }); + const v1 = since(); + const fitsBeforeExpand = selected.fits; + await expand.click(); + const expandImages = [await shot(page, 'expand1-sofort')]; + await wait(250); expandImages.push(await shot(page, 'expand1-250ms')); + await wait(750); expandImages.push(await shot(page, 'expand1-1s')); + await scopeSettled(page); + expandImages.push(await shot(page, 'expand1-fertig')); + const expanded = await state(page); + mark('expand-1', v1, since()); + const timeline = await timelineSince(page, t0); + const canvases = [...new Set(timeline.map((row) => row.canvas))]; + const errors = await page.evaluate(() => window.__probeErrors.slice()); + check('G1', 'Zeichenflaeche bleibt beim Auswaehlen und Expandieren dieselbe, Expand passt nicht neu ein', + canvases.length === 1 && canvases[0] !== null && expanded.fits === fitsBeforeExpand && errors.length === 0 && blankFrames.every((count) => count > 0), + { canvasIds: canvases, sceneNodesRightAfterSelect: blankFrames, fitsBefore: before.fits, fitsAfterSelect: fitsBeforeExpand, fitsAfterExpand: expanded.fits, pageErrors: errors }, [...images.slice(1, 3), ...expandImages]); + + // G4: die Wurzel bleibt markiert, nahe der Bildmitte, und alles bleibt im Bild. + const root = await page.locator('[data-testid="atlas-galaxy-root-marker"]').first().boundingBox().catch(() => null); + const canvasBox = await page.locator('.atlas-galaxy canvas').first().boundingBox(); + const offset = root && canvasBox ? Math.hypot(root.x + root.width / 2 - (canvasBox.x + canvasBox.width / 2), root.y + root.height / 2 - (canvasBox.y + canvasBox.height / 2)) / Math.min(canvasBox.width, canvasBox.height) : null; + check('G4', 'Wurzel nach dem Expandieren markiert, mittig und alle Knoten im Bild', + root !== null && offset !== null && offset < 0.2 && expanded.outside === 0, + { rootMarker: root, offsetFromCentre: offset === null ? null : Number(offset.toFixed(3)), outside: expanded.outside, inside: expanded.inside }, expandImages.slice(-1)); + + // G7: die Werkzeugleiste bleibt eine Zeile. + const rows = await page.evaluate(() => { + const bar = document.querySelector('.atlas-graph-exploration'); + if (!bar) return null; + const tops = [...bar.querySelectorAll('button, select, input, summary, label, span')].filter((el) => el.offsetParent !== null && el.getBoundingClientRect().height > 0) + .map((el) => Math.round(el.getBoundingClientRect().top / 12)); + return [...new Set(tops)].length; + }); + const barBox = await page.locator('.atlas-graph-exploration').first().boundingBox().catch(() => null); + check('G7', 'Galaxie-Werkzeugleiste bricht bei 1600 px nicht um', barBox !== null && barBox.height < 70, { toolbarHeight: barBox?.height ?? null, rowBuckets: rows }); + + // G3: Mausrad zoomt zum Zeiger. + const zoomImages = []; + const analysis = []; + const v2 = since(); + for (const spot of [{ label: 'rechts-oben', fx: 0.78, fy: 0.25 }, { label: 'links-unten', fx: 0.2, fy: 0.78 }]) { + await page.getByRole('button', { name: 'fit view' }).click().catch(() => {}); + await wait(1200); + const x = canvasBox.x + canvasBox.width * spot.fx; + const y = canvasBox.y + canvasBox.height * spot.fy; + await page.mouse.move(x, y, { steps: 6 }); + await wait(300); + const a = (await state(page)).camera; + zoomImages.push(await shot(page, `zoom-${spot.label}-start`)); + for (let step = 1; step <= 4; step += 1) { + await page.mouse.wheel(0, -240); + await wait(500); + zoomImages.push(await shot(page, `zoom-${spot.label}-${step}`)); + } + const b = (await state(page)).camera; + if (a && b) { + const delta = a.position.map((v, i) => b.position[i] - v); + const length = Math.hypot(...delta) || 1; + analysis.push({ spot: spot.label, moved: Number(length.toFixed(2)), along: Number((Math.abs(delta.reduce((sum, v, i) => sum + v * a.direction[i], 0)) / length).toFixed(4)) }); + } + } + mark('zoom', v2, since()); + check('G3', 'Mausrad-Zoom wandert zum Zeiger (Anteil entlang der Blickrichtung deutlich unter 1)', + analysis.length === 2 && analysis.every((row) => row.along < 0.97 && row.moved > 1), analysis, zoomImages); + + // G5: Pfad zu einem Knoten, Schritte vor und zurueck, Esc raeumt auf. + await page.getByRole('button', { name: 'fit view' }).click().catch(() => {}); + await wait(1000); + const pathImages = []; + const v3 = since(); + const picker = page.locator('details.atlas-graph-path-picker'); + let pathResult = { opened: false }; + if (await picker.count()) { + await picker.locator('summary').click(); + await wait(300); + const search = page.getByRole('searchbox', { name: 'Find a path target' }); + await search.type('Func', { delay: 25 }); + await wait(500); + pathImages.push(await shot(page, 'pfad-suche')); + const candidate = page.locator('.atlas-graph-path-menu button', { hasText: /^\W*Func\b/ }).first().or(page.locator('.atlas-graph-path-menu button').first()).first(); + if (await candidate.count()) { + await candidate.click(); + await wait(1200); + pathImages.push(await shot(page, 'pfad-angezeigt')); + const panel = page.locator('[data-testid="atlas-galaxy-path-panel"]'); + const heading = await panel.innerText().catch(() => ''); + const nextButton = panel.getByRole('button', { name: 'Next' }); + const positions = []; + for (let step = 0; step < 3 && await nextButton.isEnabled().catch(() => false); step += 1) { + await nextButton.click(); + await wait(600); + pathImages.push(await shot(page, `pfad-schritt-${step + 1}`)); + positions.push((await panel.innerText().catch(() => '')).match(/\d+ of \d+/)?.[0] ?? ''); + } + await page.mouse.move(canvasBox.x + 40, canvasBox.y + 40); + await page.keyboard.press('Escape'); + await wait(600); + pathImages.push(await shot(page, 'pfad-esc')); + const cleared = !(await panel.count()) || !(await panel.isVisible().catch(() => false)); + pathResult = { opened: true, heading: heading.split('\n').slice(0, 4).join(' | '), positions, clearedByEscape: cleared }; + } + } + mark('pfad', v3, since()); + check('G5a', 'Pfad zu einem Knoten wird angezeigt, laesst sich durchschalten und mit Esc verlassen', + pathResult.opened && /Path to .* · \d+ hops?/.test(pathResult.heading ?? '') && (pathResult.positions?.length ?? 0) > 0 && pathResult.clearedByEscape, pathResult, pathImages); + + // G5: Aufrufreihenfolge fuer eine Funktion mit Aufrufen. + const orderImages = []; + await select(page, 'call_command'); + await scopeSettled(page); + const order = page.getByRole('button', { name: 'Call order' }); + let orderResult = { available: false }; + if (await order.count() && await order.isEnabled().catch(() => false)) { + await order.click(); + await wait(1200); + orderImages.push(await shot(page, 'aufrufreihenfolge')); + const panel = page.locator('[data-testid="atlas-galaxy-path-panel"]'); + const text = await panel.innerText().catch(() => ''); + const lines = [...text.matchAll(/line (\d+)/g)].map((m) => Number(m[1])); + orderResult = { available: true, heading: text.split('\n')[0], lines, sorted: lines.every((v, i) => i === 0 || v >= lines[i - 1]) }; + await page.keyboard.press('Escape'); + } + check('G5b', 'Aufrufreihenfolge der Wurzel in Zeilenreihenfolge', orderResult.available && (orderResult.lines?.length ?? 0) > 1 && orderResult.sorted, orderResult, orderImages); +} + +/* ------------------------------------------------------------------ */ +/* Chat */ + +async function chatChecks(page) { + await open(page, PROJECT, 'galaxy'); + await galaxyReady(page); + + // C6: Roboter und Lampe in der Kopfzeile, Klick oeffnet die Konfiguration. + const action = page.locator('.atlas-browser-ai-action').first(); + const header = await action.boundingBox().catch(() => null); + const headerInfo = await action.evaluate((el) => ({ svg: Boolean(el.querySelector('svg')), text: el.textContent?.trim() ?? '', title: el.getAttribute('title') ?? '', label: el.getAttribute('aria-label') ?? '' })).catch(() => null); + const headerImage = header ? `${String(++shotIndex).padStart(3, '0')}-kopfzeile.png` : null; + if (header) await page.screenshot({ path: join(OUT, 'bilder', headerImage), clip: { x: Math.max(0, header.x - 300), y: Math.max(0, header.y - 10), width: Math.min(VIEWPORT.width - Math.max(0, header.x - 300), header.width + 360), height: header.height + 20 } }); + await action.click(); + await wait(700); + const dialogOpen = await page.getByRole('dialog').filter({ hasText: 'Agent configuration' }).isVisible().catch(() => false); + const configImage = await shot(page, 'agent-konfiguration'); + check('C6', 'Kopfzeile zeigt Roboter und Lampe, Modellname im Tooltip, Klick oeffnet die Konfiguration', + Boolean(headerInfo?.svg) && /Qwen2\.5 Coder 0\.5B/.test(`${headerInfo?.title} ${headerInfo?.label}`) && !/Qwen2\.5/.test(headerInfo?.text ?? '') && dialogOpen, + { ...headerInfo, dialogOpen }, [headerImage, configImage].filter(Boolean)); + + // C5: Token-Grenzen einstellen, nach dem Neuladen noch da. + const hasFields = (await page.locator('#cbm-chat-input-tokens').count()) > 0 && (await page.locator('#cbm-chat-output-tokens').count()) > 0; + let persisted = null; + if (hasFields) { + await page.locator('#cbm-chat-output-tokens').fill('384'); + await page.locator('#cbm-chat-output-tokens').press('Tab'); + await wait(400); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.atlas-shell'); + await galaxyReady(page); + await page.locator('.atlas-browser-ai-action').first().click(); + await wait(700); + persisted = { set: '384', afterReload: await page.locator('#cbm-chat-output-tokens').inputValue().catch(() => null), input: await page.locator('#cbm-chat-input-tokens').inputValue().catch(() => null) }; + } + check('C5', 'Eingabe- und Ausgabe-Token einstellbar und nach dem Neuladen gespeichert', hasFields && persisted?.afterReload === '384', persisted, [await shot(page, 'token-grenzen')]); + if (hasFields) { await page.locator('#cbm-chat-output-tokens').fill('512'); await page.locator('#cbm-chat-output-tokens').press('Tab'); await wait(300); } + + // Modell in DIESER Seite laden; ab hier wird nicht mehr neu geladen. + let modelReady = false; + if (MODEL) { + const load = page.getByRole('button', { name: /Download & load|Load model|Reload model/ }); + if (await load.count()) await load.first().click(); + modelReady = await page.waitForFunction(() => /Agent active/.test(`${document.querySelector('.atlas-browser-ai-action')?.getAttribute('aria-label') ?? ''}`), null, { timeout: 900000 }).then(() => true).catch(() => false); + } + await page.keyboard.press('Escape').catch(() => {}); + await wait(600); + await select(page, 'JSONBAgg'); + await scopeSettled(page); + const toggle = page.getByRole('button', { name: 'Open chat' }); + if (await toggle.count()) { await toggle.click(); await wait(1200); } + + // C3: Selection details liegt nicht auf der Eingabe, und ueber Senden liegt nichts. + const prompt = page.locator('#cbm-chat-prompt'); + const composer = await page.locator('.cbm-chat-input-row').first().boundingBox().catch(() => null); + const details = await page.locator('.atlas-galaxy-selection-details').first().boundingBox().catch(() => null); + await prompt.fill('x'); + await wait(200); + const sendBox = await page.locator('.cbm-chat-send').first().boundingBox().catch(() => null); + const topAtSend = sendBox ? await page.evaluate(({ x, y }) => { + const el = document.elementFromPoint(x, y); + return { tag: el?.tagName ?? '', cls: String(el?.className ?? ''), insideSend: Boolean(el?.closest('.cbm-chat-send')), insideDetails: Boolean(el?.closest('.atlas-galaxy-selection-details')) }; + }, { x: sendBox.x + sendBox.width / 2, y: sendBox.y + sendBox.height / 2 }) : null; + const sendClickable = modelReady ? await page.getByRole('button', { name: 'Send ↑' }).click({ trial: true, timeout: 3000 }).then(() => true).catch((error) => String(error.message).split('\n')[0]) : 'model not loaded'; + await prompt.fill(''); + check('C3', 'Selection details ueberlagert die Chat-Eingabe nicht, ueber Senden liegt nichts, Senden per Maus klickbar', + composer !== null && !intersects(composer, details) && topAtSend?.insideSend === true && sendClickable === true, + { composer, details, topAtSend, sendClickable }, [await shot(page, 'chat-offen')]); + if (!modelReady) { check('C-Modell', 'Lokales Modell geladen', false, 'Modell nicht innerhalb von 15 min aktiv'); return; } + + // C1: der Prompt der automatischen Erklaerung enthaelt Fakten, keine internen Schluessel. + await page.waitForFunction(() => { + const section = document.querySelector('.cbm-chat-explanation'); + return section && section.querySelector('.cbm-chat-retry') && !/Explaining selection|Preparing|Waiting for the complete scope/.test(section.textContent ?? ''); + }, null, { timeout: 240000 }).catch(() => {}); + const explanationImage = await shot(page, 'erklaerung'); + const sent = await page.evaluate(() => window.__probeWorker.slice()); + const autoPrompt = sent.filter((entry) => entry.kind === 'chat').map((entry) => entry.messages.map((m) => m.content).join('\n')).pop() ?? ''; + const forbidden = ['Snapshot.', 'renderedNodes', 'renderedEdges', 'Snapshot.generation', '$.relationships', 'Upstream omissions', 'Selected.roots'].filter((key) => autoPrompt.includes(key)); + const truth = await incoming('JSONBAgg'); + const callers = [...new Set(truth.filter((row) => row.type === 'CALLS').map((row) => row.source))]; + const namedInPrompt = callers.filter((name) => autoPrompt.includes(name)); + check('C1', 'Prompt nennt Beziehungen als lesbare Fakten, ohne interne Feldnamen', autoPrompt.length > 0 && forbidden.length === 0 && namedInPrompt.length === callers.length, + { forbiddenFound: forbidden, callers: callers.length, callersInPrompt: namedInPrompt.length, workerMessages: sent.length, promptStart: autoPrompt.slice(0, 700) }, [explanationImage]); + await writeFile(join(OUT, 'prompt-erklaerung.txt'), autoPrompt); + + // C2: "Who calls JSONBAgg?" nennt alle Aufrufer, ohne Wiederholung. + await prompt.click(); + await prompt.fill('Who calls JSONBAgg?'); + const v4 = since(); + await page.getByRole('button', { name: 'Send ↑' }).click(); + await wait(1500); + await page.waitForFunction(() => !document.querySelector('.cbm-chat-send[aria-label="Stop"]'), null, { timeout: 300000 }).catch(() => {}); + await wait(1000); + mark('frage-wer-ruft', v4, since()); + const answerImage = await shot(page, 'antwort-wer-ruft'); + const transcript = await page.locator('.cbm-chat-transcript').innerText().catch(() => ''); + const answer = transcript.slice(transcript.lastIndexOf('Who calls JSONBAgg?')); + const missing = callers.filter((name) => !answer.includes(name)); + const lines = answer.split('\n').map((line) => line.trim()).filter(Boolean); + const repeated = Object.entries(lines.reduce((acc, line) => { acc[line] = (acc[line] ?? 0) + 1; return acc; }, {})).filter(([line, count]) => count >= 3 && line.length > 8); + check('C2', 'Antwort auf "Who calls JSONBAgg?" nennt alle Aufrufer aus dem Index ohne Wiederholungen', callers.length > 0 && missing.length === 0 && repeated.length === 0, + { callers: callers.length, missing, repeated: repeated.slice(0, 3), answerStart: answer.slice(0, 900) }, [answerImage]); + await writeFile(join(OUT, 'antwort-wer-ruft.txt'), answer); + + // C4: zurueck zur selben Auswahl, die Erklaerung kommt aus dem Cache. + const firstExplanation = await page.locator('.cbm-chat-explanation').innerText().catch(() => ''); + await select(page, 'BaseCommand'); + await scopeSettled(page); + await page.waitForFunction(() => { const section = document.querySelector('.cbm-chat-explanation'); return !section || !/Explaining selection/.test(section.textContent ?? ''); }, null, { timeout: 240000 }).catch(() => {}); + const mark0 = await page.evaluate(() => window.__probeWorker.length); + await select(page, 'JSONBAgg'); + await scopeSettled(page); + const back1 = await page.locator('.cbm-chat-explanation').innerText().catch(() => ''); + const cacheImages = [await shot(page, 'zurueck-zu-jsonbagg')]; + await wait(4000); + cacheImages.push(await shot(page, 'zurueck-zu-jsonbagg-4s')); + const back2 = await page.locator('.cbm-chat-explanation').innerText().catch(() => ''); + const newRuns = await page.evaluate((from) => window.__probeWorker.slice(from).filter((entry) => entry.kind === 'chat').length, mark0); + check('C4', 'Rueckkehr zur selben Auswahl erzeugt keine neue Erklaerung', + newRuns === 0 && !/Explaining selection/.test(back1 + back2) && back2.replace(/Explain again/g, '').trim().length > 20, + { newModelRuns: newRuns, explainingShown: /Explaining selection/.test(back1 + back2), sameText: back2.trim() === firstExplanation.trim() }, cacheImages); +} + +/* ------------------------------------------------------------------ */ +/* Architektur */ + +async function labelOverlaps(page) { + return page.evaluate(() => { + const boxes = [...document.querySelectorAll('button.architecture-node-label')] + .filter((el) => !el.classList.contains('is-hidden') && el.offsetParent !== null) + .map((el) => ({ r: el.getBoundingClientRect(), text: el.textContent?.trim() ?? '' })) + .filter((item) => item.r.width > 0 && item.r.height > 0); + let overlaps = 0; + for (let i = 0; i < boxes.length; i += 1) for (let j = i + 1; j < boxes.length; j += 1) { + // Gemessen am Textbereich, ohne den Innenabstand des Etiketts: so entscheidet auch die Szene. + const a = boxes[i].r; const b = boxes[j].r; const ix = 6; const iy = 4; + if (!(a.left + ix >= b.right - ix || b.left + ix >= a.right - ix || a.top + iy >= b.bottom - iy || b.top + iy >= a.bottom - iy)) overlaps += 1; + } + return { labels: boxes.length, overlaps, sample: boxes.slice(0, 12).map((item) => item.text) }; + }); +} + +async function architectureChecks(page) { + const view = (name) => page.locator(`button.atlas-arch-tab[data-view="${name}"]`).first(); + await open(page, PROJECT, 'architecture'); + await view('overview').click(); + await page.waitForFunction(() => document.querySelectorAll('button.architecture-node-label').length > 2, null, { timeout: 30000 }).catch(() => {}); + await wait(2500); + const rootImage = await shot(page, 'overview-wurzel'); + const rootLabels = await labelOverlaps(page); + const area = page.locator('button.architecture-node-label', { hasText: /^\W*django\s*$/ }).first(); + let inside = null; + const images = [rootImage]; + if (await area.count()) { + await area.click(); + await wait(600); + const openArea = page.getByRole('button', { name: /Open area/ }).first(); + const v5 = since(); + if (await openArea.count()) await openArea.click(); else await area.dblclick(); + await wait(250); images.push(await shot(page, 'overview-django-250ms')); + await wait(2500); images.push(await shot(page, 'overview-django-fertig')); + mark('overview-django', v5, since()); + inside = await labelOverlaps(page); + inside.fullPaths = inside.sample.filter((label) => label.startsWith('django/')).length; + } + check('A1', 'In "django" lesbare Bereiche ohne volle Pfade und ohne gestapelte Beschriftungen', inside !== null && inside.labels > 3 && inside.overlaps <= 2 && inside.fullPaths === 0, + { root: rootLabels, inside }, images); + + await view('structure').click(); + await page.waitForFunction(() => /\d+ groups? ·/.test(document.body.textContent ?? ''), null, { timeout: 30000 }).catch(() => {}); + await wait(2500); + const structureImage = await shot(page, 'system-structure'); + const structureText = await page.locator('body').innerText(); + const groups = Number(structureText.match(/(\d[\d.,]*) groups? ·/)?.[1]?.replace(/[.,]/g, '') ?? '0'); + const backend = await tool('get_architecture', { project: PROJECT, aspects: ['system_structure'], format: 'json' }); + const result = backend?.result ?? backend; + check('A2', 'System structure fuer Django zeigt Gruppen (oder den echten Grund)', + groups > 0 || /exceeded its memory budget|limited/i.test(structureText), + { groupsShown: groups, backendStatus: result?.status, components: result?.totals?.components, entrypoints: result?.totals?.entrypoints, warnings: (result?.warnings ?? []).slice(-1) }, [structureImage]); + + await view('behavior').click(); + await wait(6000); + const start = page.getByLabel('Behavior entry point').or(page.locator('select').first()).first(); + const options = await start.locator('option').allTextContents().catch(() => []); + const behaviorImages = [await shot(page, 'behavior-startliste')]; + const realOptions = options.filter((option) => !/Choose an operation/.test(option)); + let calls = null; + if (realOptions.length) { + await start.selectOption({ index: Math.min(1, options.length - 1) }).catch(() => {}); + await wait(5000); + behaviorImages.push(await shot(page, 'behavior-ausgewaehlt')); + calls = (await page.locator('body').innerText()).match(/(\d+) direct callees?/)?.[0] ?? null; + } + check('A3', 'Behavior bietet fuer Django Einstiegspunkte an', realOptions.length > 0, { entries: realOptions.length, sample: realOptions.slice(0, 8), afterSelect: calls }, behaviorImages); + + await view('routes').click(); + await wait(2500); + const endpoints = page.getByRole('button', { name: 'Endpoints', exact: true }); + if (await endpoints.count()) { await endpoints.click(); await wait(3000); } + const routesImage = await shot(page, 'routes-endpoints'); + const toggleText = await page.locator('label.spatial-gravity-toggle', { hasText: /Include test routes/ }).innerText().catch(() => ''); + const routeLabels = await labelOverlaps(page); + check('A4', 'Endpoints gruppiert, Test-Routen standardmaessig ausgeblendet, Beschriftungen ohne Stapel', + /Include test routes/.test(toggleText) && routeLabels.overlaps <= 3, { toggle: toggleText, labels: routeLabels.labels, overlaps: routeLabels.overlaps, sample: routeLabels.sample }, [routesImage]); + + // Gegenprobe: dieses Repository behaelt seine Ansichten. + await open(page, CONTROL, 'architecture'); + await wait(3500); + await view('behavior').click(); + await wait(6000); + const controlOptions = await page.getByLabel('Behavior entry point').or(page.locator('select').first()).first().locator('option').allTextContents().catch(() => []); + check('A-Gegenprobe', 'Gegenprobe: Behavior im eigenen Repository hat weiterhin Einstiegspunkte', controlOptions.length > 10, { entries: controlOptions.length }, [await shot(page, 'gegenprobe-behavior')]); +} + +/* ------------------------------------------------------------------ */ +/* Bildstreifen aus dem Video */ + +async function strips(videoPath) { + if (!FFMPEG || !videoPath) return []; + const made = []; + for (const item of marks) { + const file = join(OUT, 'streifen', `${item.label}.png`); + const duration = Math.max(1, Math.min(12, item.to - item.from + 0.5)); + // Sechs Bilder pro Sekunde, sechs je Zeile: eine Zeile pro Sekunde. + const rows = Math.ceil(duration); + try { + await run(FFMPEG, ['-y', '-loglevel', 'error', '-ss', String(Math.max(0, item.from - 0.3)), '-t', String(duration), '-i', videoPath, + '-vf', `fps=6,scale=400:-1,tile=6x${rows}:padding=4:color=black`, '-frames:v', '1', file]); + made.push(file); + } catch (error) { + console.error(`[streifen] ${item.label}: ${String(error?.stderr ?? error).split('\n')[0]}`); + } + } + return made; +} + +/* ------------------------------------------------------------------ */ + +await mkdir(join(OUT, 'bilder'), { recursive: true }); +await mkdir(join(OUT, 'streifen'), { recursive: true }); +await mkdir(join(OUT, 'video'), { recursive: true }); +const context = await chromium.launchPersistentContext(PROFILE, { + headless: false, viewport: VIEWPORT, deviceScaleFactor: 2, + args: ['--enable-unsafe-webgpu', '--ignore-gpu-blocklist'], + recordVideo: { dir: join(OUT, 'video'), size: VIEWPORT }, +}); +await context.addInitScript(pageProbe); +const page = context.pages()[0] ?? await context.newPage(); +const pageErrors = []; +page.on('pageerror', (error) => { pageErrors.push(error.message); console.error(`[pageerror] ${error.message}`); }); +const failures = []; +const section = async (name, fn) => { + try { await fn(); } catch (error) { failures.push({ name, error: String(error?.stack ?? error) }); console.error(`[${name}] ABBRUCH ${error?.message ?? error}`); } +}; +if (ONLY.includes('galaxy')) await section('galaxy', () => galaxyChecks(page)); +if (ONLY.includes('chat')) await section('chat', () => chatChecks(page)); +if (ONLY.includes('architecture')) await section('architecture', () => architectureChecks(page)); +check('X1', 'Keine Seitenfehler im gesamten Lauf', pageErrors.length === 0, { pageErrors }); +const video = page.video(); +await context.close(); +let videoPath = null; +try { + const raw = await video?.path(); + if (raw) { videoPath = join(OUT, 'video', 'lauf.webm'); await rename(raw, videoPath); } +} catch { videoPath = (await readdir(join(OUT, 'video'))).map((name) => join(OUT, 'video', name)).find((name) => name.endsWith('.webm')) ?? null; } +const made = await strips(videoPath); +const passed = checks.filter((item) => item.pass).length; +await writeFile(join(OUT, 'report.json'), JSON.stringify({ origin: ORIGIN, project: PROJECT, control: CONTROL, passed, total: checks.length, checks, failures, marks, strips: made, video: videoPath }, null, 2)); +const lines = ['# Abnahme Call-Feedback', '', `${passed} von ${checks.length} Pruefungen bestanden.`, '', '| Punkt | Pruefung | Ergebnis | Messwert |', '|---|---|---|---|']; +for (const item of checks) lines.push(`| ${item.id} | ${item.title} | ${item.pass ? 'bestanden' : 'NICHT bestanden'} | ${JSON.stringify(item.measured).slice(0, 240).replace(/\|/g, '/')} |`); +if (failures.length) lines.push('', '## Abbrueche', '', ...failures.map((item) => `- ${item.name}: ${item.error.split('\n')[0]}`)); +await writeFile(join(OUT, 'report.md'), `${lines.join('\n')}\n`); +console.log(`${passed}/${checks.length} bestanden`); +if (passed !== checks.length || failures.length) process.exitCode = 1;