Files
goodbuddy/tests/support/workspace-files-layout-driver.mjs
T

160 lines
10 KiB
JavaScript

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()')
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.every(value => value <= 1), 'Unexpected horizontal overflow')
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())
}
for (const index of [1, 2]) {
await click(`.workspace-files__actions button:nth-child(${index})`)
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)
}
})