// Exercise the real portal frontend with deterministic permission-filtered responses. import fs from "node:fs"; import assert from "node:assert/strict"; import os from "node:os"; import path from "node:path"; import {createRequire} from "node:module"; import {fileURLToPath} from "node:url"; const require = createRequire(import.meta.url); const {chromium} = require(process.env.PLAYWRIGHT_MODULE || "playwright"); const root = fileURLToPath(new URL("../app/web/", import.meta.url)); const out = process.env.SCREENSHOT_DIR || fs.mkdtempSync(path.join(os.tmpdir(), "adfs-portal-review-")); fs.mkdirSync(out,{recursive:true}); const sources=[{id:'data:finance',label:'Finanzen',kind:'data'},{id:'data:projects',label:'Projekte',kind:'data'},{id:'private:alice',label:'Private',kind:'private'}]; const fixtures=[ ['Rechnung Oktober 2026.pdf','pdf','data:finance','Rechnung für Büroausstattung\nReferenz FINANZ742\nGesamtbetrag 1.500,00 EUR',true], ['Projektplan für die Einführung des neuen Dokumentenportals und die Schulung aller Mitarbeiter.docx','docx','data:projects','Projektplan mit Schulung und Zeitplan. Start im Oktober.',false], ['Meine Notizen.txt','txt','private:alice','',false], ['Umsatzübersicht.csv','csv','data:finance','',false], ['Gescanntes Protokoll.pdf','pdf','data:projects','',true] ].map(([name,extension,sourceId,text,hasPreview],index)=>({id:(index+1).toString(16).padStart(32,'0'),name,extension,sourceId,text,path:'Dokumente/'+name,source:sources.find(source=>source.id===sourceId).label,kind:sources.find(source=>source.id===sourceId).kind,size:143360+index*1024,modified:1791023400-index*86400,state:index===4?'ocr':'ready',pages:extension==='pdf'?3:0,hasPreview,version:'aaaaaaaaaaaaaaaa',snippet:text})); // A small native three-page PDF exercises the real viewer, including its find bar. function viewerPdf() { const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>']; for(let page=1;page<=3;page++){ const content=`BT /F1 24 Tf 72 720 Td (PDFVIEWER742 - Page ${page}) Tj ET`; objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 9 0 R >> >> /Contents ${page*2+2} 0 R >>`); objects.push(`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`); } objects.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'); let pdf='%PDF-1.7\n', offsets=[0]; objects.forEach((object,index)=>{offsets.push(Buffer.byteLength(pdf));pdf+=`${index+1} 0 obj\n${object}\nendobj\n`;}); const offset=Buffer.byteLength(pdf);pdf+=`xref\n0 10\n0000000000 65535 f \n`; offsets.slice(1).forEach(value=>{pdf+=`${String(value).padStart(10,'0')} 00000 n \n`;}); return Buffer.from(pdf+`trailer\n<< /Size 10 /Root 1 0 R >>\nstartxref\n${offset}\n%%EOF\n`); } const pdf=viewerPdf(); let role='user', loggedIn=true, deniedId='', pendingRequests=[], errors=[]; const browser=await chromium.launch({headless:true}); try { const page=await browser.newPage({viewport:{width:1500,height:1050}}); page.on('pageerror',error=>errors.push(error.message)); await page.route('http://adfs.test/**',async route=>{ const url=new URL(route.request().url()); let body,type='application/json',status=200; if(url.pathname==='/api/session'){ if(!loggedIn){status=401;body={error:'Anmeldung erforderlich'};}else body={user:'EXAMPLE\\alice',role,expiresAt:9999999999}; }else if(url.pathname==='/api/login'){loggedIn=true;body={user:'EXAMPLE\\alice',role,expiresAt:9999999999};} else if(url.pathname==='/api/logout'){loggedIn=false;body={ok:true};} else if(url.pathname==='/api/documents'){ pendingRequests.push(url.searchParams.get('q')); const query=(url.searchParams.get('q')||'').toLowerCase(),scope=url.searchParams.get('scope'); let rows=fixtures.filter(row=>row.id!==deniedId); if(query)rows=rows.filter(row=>(scope==='name'?row.name:scope==='content'?row.text:row.name+' '+row.text).toLowerCase().includes(query)); if(url.searchParams.get('source'))rows=rows.filter(row=>row.sourceId===url.searchParams.get('source')); if(url.searchParams.get('type'))rows=rows.filter(row=>row.extension===url.searchParams.get('type')); body={items:rows,total:rows.length,hasMore:false,sources,types:['pdf','docx','txt','csv'],pending:rows.filter(row=>row.state==='ocr').length}; }else if(url.pathname.startsWith('/api/documents/')){ const parts=url.pathname.split('/'),row=fixtures.find(row=>row.id===parts[3]); if(!row||row.id===deniedId){status=404;body={error:'Datei nicht verfügbar'};} else if(parts[4]==='preview'){ if(row.previewWidth){ const width=row.previewWidth,height=row.previewHeight; type='image/svg+xml';body=Buffer.from(`Oben · ${width} × ${height}Unten · vollständiges Bild`); }else{ type='image/svg+xml';body=Buffer.from(`RECHNUNGOktober 2026 · FINANZ742Büroausstattung3 × Bildschirm1.500,00 EURGesamtbetrag: 1.500,00 EUR`); } }else if(parts[4]==='content'){type='application/pdf';body=pdf;}else body=row; }else if(url.pathname.startsWith('/assets/vendor/pdfjs/6.3.289-app1/')){ const relative=url.pathname.slice('/assets/vendor/pdfjs/6.3.289-app1/'.length); const filename=root+'vendor/pdfjs/6.3.289-app1/'+relative; type=({'.html':'text/html','.mjs':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.ftl':'text/plain','.json':'application/json','.wasm':'application/wasm','.ttf':'font/ttf'})[path.extname(filename)]||'application/octet-stream'; body=fs.readFileSync(filename); }else if(url.pathname==='/assets/pdf-viewer.js'){type='text/javascript';body=fs.readFileSync(root+'pdf-viewer.js');} else if(url.pathname==='/assets/pdf-viewer.css'){type='text/css';body=fs.readFileSync(root+'pdf-viewer.css');} else if(url.pathname==='/assets/portal.js'){type='text/javascript';body=fs.readFileSync(root+'portal.js');} else if(url.pathname==='/assets/styles.css'){type='text/css';body=fs.readFileSync(root+'styles.css');} else {type='text/html';body=fs.readFileSync(root+'portal.html');} await route.fulfill({status,contentType:type,body:Buffer.isBuffer(body)?body:JSON.stringify(body)}); }); const shot=async name=>{await page.waitForFunction(()=>Array.from(document.images).filter(img=>img.loading!=='lazy'||img.getBoundingClientRect().topimg.complete));await page.screenshot({path:path.join(out,name+'.png'),fullPage:!await page.locator('#document-dialog').isVisible()});}; const all=()=>page.waitForFunction(()=>document.querySelectorAll('.document-card').length===5); await page.goto('http://adfs.test/');await all(); assert.equal(await page.locator('#admin-link').isHidden(),true); assert.equal(await page.locator('nav a[href^="/admin"]').count(),0); assert.equal(await page.locator('#source-filter').textContent().then(text=>text.includes('Mein Private-Ordner')),true); assert.match(await page.locator('#index-progress').innerText(),/1 Datei/); assert.equal(await page.locator('.document-excerpt').count(),0); assert.equal(await page.locator('#documents').innerText().then(text=>text.includes('Referenz FINANZ742')||text.includes('Projektplan mit Schulung')),false); await shot('01-portal-desktop'); await page.locator('#search-query').fill('FINANZ742'); await page.waitForFunction(()=>document.querySelectorAll('.document-card').length===1); assert.match(await page.locator('.document-card').innerText(),/Rechnung Oktober/); assert.equal(await page.locator('.document-card').innerText().then(text=>text.includes('FINANZ742')),false); await page.locator('#search-scope').selectOption('content'); await page.locator('[data-document]').click(); await page.locator('#document-dialog').waitFor({state:'visible'}); assert.equal(await page.locator('.document-text, #document-content').count(),0); const viewer=page.frameLocator('#document-pdf-viewer'); await viewer.locator('.page canvas').first().waitFor(); assert.deepEqual(await page.locator('#document-dialog').boundingBox(),{x:0,y:0,width:1500,height:1050}); await viewer.locator('#pageNumber').fill('2');await viewer.locator('#pageNumber').press('Enter'); await page.waitForFunction(()=>document.querySelector('#document-pdf-viewer').contentWindow.PDFViewerApplication.page===2); await page.waitForTimeout(2500); assert.equal(await viewer.locator('#pageNumber').inputValue(),'2','Detail polling must preserve the selected page'); const scale=await page.evaluate(()=>document.querySelector('#document-pdf-viewer').contentWindow.PDFViewerApplication.pdfViewer.currentScale); await viewer.locator('#zoomInButton').click(); await page.waitForFunction(old=>document.querySelector('#document-pdf-viewer').contentWindow.PDFViewerApplication.pdfViewer.currentScale>old,scale); await viewer.locator('#viewFindButton').click();await viewer.locator('#findInput').fill('PDFVIEWER742'); await viewer.locator('#findResultsCount').filter({hasText:/3/}).waitFor(); assert.equal(await page.locator('#document-download').getAttribute('href'),'/api/documents/'+fixtures[0].id+'/download'); await shot('02-portal-document-preview'); await page.locator('#document-close').click(); assert.notEqual(await page.evaluate(()=>getComputedStyle(document.documentElement).overflowY),'hidden'); await page.locator('#reset-filters').click();await all(); await page.locator(`[data-document="${fixtures[1].id}"]`).click(); await page.locator('#document-dialog').waitFor({state:'visible'}); assert.equal(await page.locator('.document-text, #document-content, #document-pdf-viewer').count(),0); assert.equal(await page.locator('#document-dialog').innerText().then(text=>text.includes('Projektplan mit Schulung')),false); assert.equal(await page.evaluate(()=>getComputedStyle(document.documentElement).overflowY),'hidden'); assert.ok((await page.locator('#document-dialog').boundingBox()).height<400,'Files without a visual preview should have a compact dialog'); await shot('02b-office-without-document-text'); for(const extension of ['md','odt']) { await page.locator('#document-close').click();fixtures[1].extension=extension; await page.locator(`[data-document="${fixtures[1].id}"]`).click(); await page.locator('#document-dialog').waitFor({state:'visible'}); assert.equal(await page.locator('.document-text, #document-content, #document-pdf-viewer').count(),0); assert.equal(await page.locator('#document-dialog').innerText().then(text=>text.includes('Projektplan mit Schulung')),false); } fixtures[1].extension='docx'; await page.locator('#document-close').click(); // Large images must fit in both dimensions, including after resizing an open dialog. const originalOffice={...fixtures[1]}; for(const [orientation,width,height] of [['portrait',4000,7000],['landscape',7000,4000],['small',180,240]]){ Object.assign(fixtures[1],{extension:'png',name:'Bildvorschau.png',path:'Dokumente/Ein sehr langer Ordnername für die Prüfung mehrzeiliger Metadaten/Bildvorschau.png',hasPreview:true,previewWidth:width,previewHeight:height,version:width.toString(16).padStart(16,'0')}); await page.setViewportSize({width:1500,height:1050}); await page.locator(`[data-document="${fixtures[1].id}"]`).click(); await page.locator('#document-preview img').waitFor(); await page.waitForFunction(()=>{const img=document.querySelector('#document-preview img');return img?.complete&&img.naturalWidth>0;}); for(const [device,viewport] of [['desktop',{width:1500,height:1050}],['mobile',{width:390,height:844}],['mobile-landscape',{width:844,height:390}]]){ await page.setViewportSize(viewport); const metrics=await page.evaluate(()=>{ const dialog=document.querySelector('#document-dialog'),preview=document.querySelector('#document-preview'),img=preview.querySelector('img'); const bounds=element=>{const {x,y,width,height}=element.getBoundingClientRect();return {x,y,width,height};}; return {dialog:bounds(dialog),preview:bounds(preview),image:bounds(img),naturalWidth:img.naturalWidth,naturalHeight:img.naturalHeight,scrolls:[dialog,preview].map(element=>({x:element.scrollWidth-element.clientWidth,y:element.scrollHeight-element.clientHeight}))}; }); assert.deepEqual([metrics.naturalWidth,metrics.naturalHeight],[width,height]); for(const scroll of metrics.scrolls){assert.ok(scroll.x<=1,'Image preview must not scroll horizontally');assert.ok(scroll.y<=1,'Image preview must not scroll vertically');} assert.ok(Math.abs(metrics.image.width/metrics.image.height-width/height)<0.01,'Image aspect ratio must be preserved'); assert.ok(metrics.image.width<=width&&metrics.image.height<=height,'Small images must not be enlarged'); assert.ok(metrics.image.width>0&&metrics.image.height>0,'Image must remain visible'); assert.ok(metrics.image.x>=metrics.preview.x&&metrics.image.y>=metrics.preview.y); assert.ok(metrics.image.x+metrics.image.width<=metrics.preview.x+metrics.preview.width+1); assert.ok(metrics.image.y+metrics.image.height<=metrics.preview.y+metrics.preview.height+1); assert.ok(metrics.dialog.y>=0&&metrics.dialog.y+metrics.dialog.height<=viewport.height+1,'Dialog must fit the viewport'); assert.equal(await page.evaluate(()=>getComputedStyle(document.documentElement).overflowY),'hidden'); await shot(`06-image-${orientation}-${device}`); } await page.locator('#document-close').click(); } delete fixtures[1].previewWidth;delete fixtures[1].previewHeight; Object.assign(fixtures[1],originalOffice); await page.setViewportSize({width:1500,height:1050}); await page.locator('#source-filter').selectOption('private:alice'); await page.waitForFunction(()=>document.querySelectorAll('.document-card').length===1); assert.match(await page.locator('.document-card').innerText(),/Meine Notizen/); await page.locator('#reset-filters').click();await all(); await page.locator('#view-list').click(); assert.equal(await page.locator('#documents').evaluate(element=>element.classList.contains('document-list')),true); assert.equal(await page.locator('.document-excerpt').count(),0); assert.equal(await page.locator('#documents').innerText().then(text=>text.includes('Referenz FINANZ742')||text.includes('Projektplan mit Schulung')),false); await shot('03-portal-list'); // A background OCR completion becomes searchable without reloading. fixtures[4].text='Erkanntes Protokoll mit dem Suchwort SCANLIVE742';fixtures[4].snippet=fixtures[4].text;fixtures[4].state='ready'; await page.locator('#search-query').fill('SCANLIVE742'); await page.waitForFunction(()=>document.querySelectorAll('.document-card').length===1); await page.locator('[data-document]').click(); await page.locator('#document-dialog').waitFor({state:'visible'}); assert.equal(await page.locator('.document-text, #document-content').count(),0); deniedId=fixtures[4].id; await page.locator('#document-dialog').waitFor({state:'hidden',timeout:6000}); assert.match(await page.locator('#toast').innerText(),/nicht verfügbar/); assert.notEqual(await page.evaluate(()=>getComputedStyle(document.documentElement).overflowY),'hidden'); deniedId=''; await page.locator('#reset-filters').click();await all(); await page.locator('#view-grid').click(); for(const width of [900,390]){ await page.setViewportSize({width,height:width===390?844:1000}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'Portal overflows at '+width); await shot('04-portal-'+width); } await page.evaluate(()=>window.scrollTo(0,300)); await page.locator('[data-document]').first().click(); await page.locator('#document-dialog').waitFor({state:'visible'}); assert.equal(await page.evaluate(()=>document.querySelector('#document-dialog').scrollWidth>document.querySelector('#document-dialog').clientWidth),false); await page.frameLocator('#document-pdf-viewer').locator('.page canvas').first().waitFor(); assert.deepEqual(await page.locator('#document-dialog').boundingBox(),{x:0,y:0,width:390,height:844}); const backgroundScroll=await page.evaluate(()=>scrollY); assert.ok(backgroundScroll>0,'Use a genuinely scrolled background for the modal test'); assert.equal(await page.evaluate(()=>getComputedStyle(document.documentElement).overflowY),'hidden'); assert.equal(await page.evaluate(()=>getComputedStyle(document.body).overflowY),'hidden'); await page.mouse.move(40,20);await page.mouse.wheel(0,600);await page.waitForTimeout(200); assert.equal(await page.evaluate(()=>scrollY),backgroundScroll,'Header wheel must not scroll the background'); const pdfScroll=await page.evaluate(()=>document.querySelector('#document-pdf-viewer').contentDocument.querySelector('#viewerContainer').scrollTop); await page.mouse.move(200,300);await page.mouse.wheel(0,600); await page.waitForFunction(before=>document.querySelector('#document-pdf-viewer').contentDocument.querySelector('#viewerContainer').scrollTop>before,pdfScroll); assert.equal(await page.evaluate(()=>scrollY),backgroundScroll,'PDF scroll must leave the background stationary'); await page.evaluate(()=>{const viewer=document.querySelector('#document-pdf-viewer').contentDocument.querySelector('#viewerContainer');viewer.scrollTop=viewer.scrollHeight;}); await page.mouse.wheel(0,600);await page.waitForTimeout(200); assert.equal(await page.evaluate(()=>scrollY),backgroundScroll,'Scrolling past the PDF end must not scroll the background'); await shot('05-portal-mobile-document');await page.locator('#document-close').click(); assert.notEqual(await page.evaluate(()=>getComputedStyle(document.documentElement).overflowY),'hidden'); assert.equal(await page.evaluate(()=>scrollY),backgroundScroll,'Closing must preserve the original scroll position'); await page.mouse.move(200,300);await page.mouse.wheel(0,300); await page.waitForFunction(before=>scrollY>before,backgroundScroll); await page.evaluate(()=>window.scrollTo(0,0)); await page.locator('#logout').click();await page.locator('#login-view').waitFor({state:'visible'}); await page.locator('#login-form input[name=username]').fill('alice'); await page.locator('#login-form input[name=password]').fill('password'); await page.locator('#login-form button').click();await all(); assert.equal(await page.locator('#admin-link').isHidden(),true); role='domain-admin';await page.goto('http://adfs.test/');await all(); assert.equal(await page.locator('#admin-link').getAttribute('href'),'/admin/overview'); assert.equal(await page.locator('#admin-link').isVisible(),true); assert.ok(pendingRequests.includes('FINANZ742')&&pendingRequests.includes('SCANLIVE742')); assert.deepEqual(errors,[]); console.log('PASS: user login, admin separation, live content search, own-private filter, preview/download links, OCR refresh, revocation, grid/list, desktop/mobile, portrait/landscape image fitting.'); console.log('Screenshots: '+out); } finally {await browser.close();}