import { app, BrowserWindow } from 'electron' import assert from 'node:assert/strict' import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import process from 'node:process' import { setTimeout } from 'node:timers/promises' import { setTimeout as startTimeout } from 'node:timers' const directory = process.env.GB_LAYOUT_DIRECTORY const artifacts = process.env.GB_LAYOUT_ARTIFACTS app.setPath('userData', join(directory, 'profile')) app.setPath('sessionData', join(directory, 'session')) app.setAppLogsPath(join(directory, 'logs')) app.commandLine.appendSwitch('force-device-scale-factor', '1') app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion') app.whenReady().then(async () => { const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }) const observations = [], errors = [] let pending = 'startup' const environment = { platform: process.platform, arch: process.arch, electron: process.versions.electron, locale: 'en-US' } const report = async result => { const text = JSON.stringify({ ...result, environment, pending, observations, errors, modelCalls: 0 }, null, 2) await writeFile(join(directory, 'result.json'), text) if (artifacts) await writeFile(join(artifacts, 'result.json'), text) } startTimeout(async () => { await report({ passed: false, error: 'Driver timeout' }); app.exit(1) }, 80000) win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) }) win.webContents.on('render-process-gone', (_event, details) => errors.push(JSON.stringify(details))) const js = code => { pending = code; return win.webContents.executeJavaScript(code) } const wait = async code => { for (let i = 0; i < 240; i++) { if (await js(code)) return; await setTimeout(25) } throw new Error(`Timeout: ${code}\n${errors.join('\n')}`) } const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))') const point = selector => js(`(() => { const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2))}; })()`) const click = async selector => { win.focus(); win.webContents.focus() await wait('document.hasFocus()') await js(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'nearest',inline:'nearest'})`) await settle() const p = await point(selector) win.webContents.sendInputEvent({ type: 'mouseMove', x: p.x, y: p.y }) await settle() assert((await point(selector)).hit, `Control occluded: ${selector}`) win.webContents.sendInputEvent({ type: 'mouseDown', x: p.x, y: p.y, button: 'left', clickCount: 1 }) win.webContents.sendInputEvent({ type: 'mouseUp', x: p.x, y: p.y, button: 'left', clickCount: 1 }) await settle() } const screenshot = async name => { if (artifacts) await writeFile(join(artifacts, `${name}.png`), (await win.webContents.capturePage()).toPNG()) } const viewport = "document.querySelector('.workspace-files__files-view')" const geometry = () => js(`(() => { const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,bottom:r.bottom,right:r.right}}; const v=${viewport},body=document.querySelector('.assistant-sidebar__body'),nav=document.querySelector('.workspace-files__navigation'); const selectors=['.workspace-files__header','.workspace-files__actions','.workspace-files__breadcrumbs']; const controls=[...document.querySelectorAll('.workspace-files__header button,.workspace-files__actions button,.workspace-files__breadcrumbs button')]; return {viewport:rect(v),scrollTop:v.scrollTop,scrollHeight:v.scrollHeight,clientHeight:v.clientHeight,overflow:getComputedStyle(v).overflowY, fixed:selectors.map(s=>{const e=document.querySelector(s);return e?rect(e):null}), controls:controls.map(e=>{const r=e.getBoundingClientRect();return {label:e.getAttribute('aria-label')||e.textContent,hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),outside:!v.contains(e),...rect(e)}}), bodyScroll:body.scrollTop,bodyOverflow:body.scrollHeight-body.clientHeight,nav:rect(nav), horizontalOverflow:[document.documentElement,body,nav,v].map(e=>e.scrollWidth-e.clientWidth), innerWidth,innerHeight,scale:devicePixelRatio}; })()`) const docked = (before, after) => { assert.deepEqual(after.fixed, before.fixed, 'Header/actions/breadcrumbs moved with files') assert(after.controls.every(c => c.hit && c.outside && c.height > 0), 'Docked controls must remain visible and hit-testable') assert.equal(after.bodyScroll, 0, 'Sidebar body must not scroll with files') assert(after.bodyOverflow <= 1, 'Files must not overflow the sidebar body') assert(after.horizontalOverflow.slice(0, 3).every(value => value <= 1), 'Files must scroll inside their viewport without widening the sidebar') assert(after.viewport.bottom <= after.nav.bottom + 1, 'Files viewport exceeds navigation bounds') assert(after.viewport.y >= Math.max(...after.fixed.filter(Boolean).map(r => r.bottom)) - 1, 'Controls overlap files viewport') } try { if (artifacts) await mkdir(artifacts, { recursive: true }) for (const git of [false, true]) for (const theme of ['light', 'dark']) { for (const [width, height, sidebar] of [[1280, 800, 560], [960, 720, 420], [720, 640, 320], [640, 420, 300]]) { const name = `${git ? 'git' : 'non-git'}-${theme}-${width}x${height}` win.setContentSize(width, height) await win.loadURL(`${process.env.GB_LAYOUT_URL}?git=${git}&theme=${theme}&sidebar=${sidebar}`) // The tree is windowed (P5): only rows near the viewport are mounted. await wait(`document.querySelectorAll('.workspace-files__row').length>0 && !!${viewport}`) await settle() assert(await js(`document.querySelectorAll('.workspace-files__row').length<121`), 'Long files tree must be windowed') await js('document.fonts.ready') await settle() assert.equal(await js('document.querySelectorAll(".workspace-files__view-switch button").length'), git ? 2 : 1) assert.equal(await js('document.querySelector(".workspace-files__view-switch button").textContent'), 'Files') assert.equal(await js('document.querySelector(".workspace-files__view-switch button").getAttribute("aria-pressed")'), 'true') const root = await geometry() assert.equal(root.innerWidth, width); assert.equal(root.innerHeight, height) assert(root.scrollHeight > root.clientHeight * 3 && root.clientHeight > 50, 'Long files list needs a bounded viewport') assert.equal(root.overflow, 'auto') docked(root, root) const p = await point('.workspace-files__files-view') win.webContents.sendInputEvent({ type: 'mouseWheel', x: p.x, y: p.y, deltaY: -240, deltaX: 0 }) await wait(`${viewport}.scrollTop > 0`) // Native wheel scrolling finishes asynchronously in Chromium. await setTimeout(350) docked(root, await geometry()) await js(`${viewport}.scrollTop=0`) await settle() await click('.workspace-files__entry .workspace-files__more') await wait('!!document.querySelector("[role=menuitem]")') await click('[role=menuitem]:first-child') await wait('!!document.querySelector(".workspace-files__breadcrumbs [aria-current=location]") && document.querySelectorAll(".workspace-files__row").length>0') const before = await geometry() assert.equal(await js('document.querySelector(".workspace-files__breadcrumbs [aria-current=location]").textContent'), 'documents') const last = '.workspace-files__entry:last-child .workspace-files__row' // Scrolling to the end re-windows; the measured row heights may grow the // list, so keep scrolling until the last file is mounted at the bottom. for (let i = 0; i < 10; i++) { await js(`${viewport}.scrollTop=${viewport}.scrollHeight`) await settle() if (await js(`document.querySelector(${JSON.stringify(last)})?.title==='documents/file-120-workspace-notes.md' && Math.abs(${viewport}.scrollHeight-${viewport}.clientHeight-${viewport}.scrollTop)<=1`)) break } const after = await geometry() assert(after.scrollTop > 0) docked(before, after) assert(await js(`(() => {const r=document.querySelector(${JSON.stringify(last)}).getBoundingClientRect(),v=${viewport}.getBoundingClientRect();return r.top>=v.top && r.bottom<=v.bottom+1})()`), 'Last file must be fully visible') assert.equal(await js(`document.querySelector(${JSON.stringify(last)}).title`), 'documents/file-120-workspace-notes.md') await screenshot(name) await click(last) await wait('!!document.querySelector("#return-to-files")') assert.equal(await js('getComputedStyle(document.querySelector(".workspace-files__navigation")).display'), 'none') await click('#return-to-files') await wait('!document.querySelector("#return-to-files")') assert.equal(await js(`${viewport}.scrollTop`), after.scrollTop, 'Preview return must retain files scroll') assert.equal(await js('document.querySelector(".workspace-files__row[aria-current=true]").title'), 'documents/file-120-workspace-notes.md') docked(before, await geometry()) if (git) { await click('.workspace-files__view-switch button:last-child') assert.equal(await js(`getComputedStyle(${viewport}).display`), 'none') await click('.workspace-files__view-switch button:first-child') assert.equal(await js(`${viewport}.scrollTop`), after.scrollTop, 'Git view return must retain files scroll') docked(before, await geometry()) } await js(`${viewport}.scrollLeft=0`) for (const label of ['New file', 'New directory']) { await click(`.workspace-files__actions button[aria-label="${label}"]`) await wait('!!document.querySelector("[role=dialog]")') win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) await wait('!document.querySelector("[role=dialog]")') } observations.push({ name, git, theme, sidebar, root, before, after, previewRetention: true, gitRetention: git, createDialogs: 2 }) } } assert.deepEqual(errors, []) await report({ passed: true }) app.exit(0) } catch (error) { await screenshot('failure') await report({ passed: false, error: String(error.stack) }) app.exit(1) } })