Load Supervisor graphs only when opened, preserve source details on demand, reuse indexed story digests and window selection lists without losing keyboard or focus behavior. Route heavy UI reads through the existing worker, batch scoped queries and construct each publication snapshot once. Keep remote cancellation responsive during model-route cleanup, remove late routes and retain reported usage after response-cap cancellation. Preserve Telegram pending replies through polling conflicts, show delivery errors and honor the final rate-limit cooldown. Schema 62 adds lookup indexes without rewriting user content; older clients cannot open upgraded data. Large review publication still blocks Main and remains documented follow-up work. Record the future storage-service direction without performing the architecture migration. Validation: exact staged code passed isolated full tests (6212 passed, 87 skipped), typecheck and lint. Three-round source performance comparisons, SQLite migration equivalence, native Electron interaction checks and current-source Linux x64 Host validation completed; eight external text-model requests. Release note: Supervisor opens reviews without loading the entire graph, keeps long selection lists responsive and preserves details and keyboard navigation. Remote cancellation and Telegram reply recovery are more reliable; remote fixes require an Agent build containing this change.
1609 lines
111 KiB
JavaScript
1609 lines
111 KiB
JavaScript
import { app, BrowserWindow } from 'electron'
|
|
import assert from 'node:assert/strict'
|
|
import { mkdir, writeFile as writeArtifact } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import process from 'node:process'
|
|
import console from 'node:console'
|
|
import { setTimeout } from 'node:timers/promises'
|
|
import { validateSupervisorSelection } from './supervisor-selection-driver.mjs'
|
|
|
|
const directory = process.env.GOODBUDDY_SUPERVISOR_DIRECTORY
|
|
const artifacts = process.env.GOODBUDDY_SUPERVISOR_ARTIFACTS || directory
|
|
const writeFile = (path, data) => process.env.GOODBUDDY_SUPERVISOR_NO_SCREENSHOT && path.endsWith('.png') ? Promise.resolve() : writeArtifact(path, data)
|
|
app.setPath('userData', join(directory, 'profile'))
|
|
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
|
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
|
|
app
|
|
.whenReady()
|
|
.then(async () => {
|
|
await mkdir(artifacts, { recursive: true })
|
|
const win = new BrowserWindow({
|
|
show: false,
|
|
width: 1440,
|
|
height: 1100,
|
|
useContentSize: true,
|
|
webPreferences: {
|
|
sandbox: true,
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
backgroundThrottling: false
|
|
}
|
|
})
|
|
const errors = []
|
|
win.webContents.on('console-message', (event) => {
|
|
if (event.level === 'error') errors.push(event.message)
|
|
})
|
|
const js = async (code) => {
|
|
try {
|
|
return await win.webContents.executeJavaScript(code)
|
|
} catch (error) {
|
|
throw new Error(`${code}\n${errors.join('\n')}`, { cause: error })
|
|
}
|
|
}
|
|
const settle = async () => {
|
|
await js(
|
|
'new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))'
|
|
)
|
|
await setTimeout(250)
|
|
}
|
|
const wait = async (code) => {
|
|
for (let i = 0; i < 600; i++) {
|
|
if (await js(code)) return
|
|
await setTimeout(25)
|
|
}
|
|
throw new Error(`Timeout: ${code}\n${errors.join('\n')}`)
|
|
}
|
|
const open = async (dense) => {
|
|
await win.loadURL(
|
|
process.env.GOODBUDDY_SUPERVISOR_URL + (dense ? '?dense=1' : '')
|
|
)
|
|
await wait('!!document.querySelector("#supervisor-tab-graph")')
|
|
await js('document.querySelector("#supervisor-tab-graph").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__map")')
|
|
await js('document.fonts.ready')
|
|
await settle()
|
|
}
|
|
const reports = []
|
|
if (process.env.GOODBUDDY_SUPERVISOR_WINDOWING) {
|
|
await validateSupervisorSelection(win, js, wait, process.env.GOODBUDDY_SUPERVISOR_URL)
|
|
assert.deepEqual(errors, [])
|
|
console.log('Supervisor source Electron selection, history, source and edit interactions passed')
|
|
win.destroy(); app.quit(); return
|
|
}
|
|
const selectHistory = async (id) => {
|
|
await js('document.querySelector(".supervisor-workspace__result-navigation button").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__history-menu")')
|
|
await js(`[...document.querySelectorAll('.supervisor-workspace__history-menu button')].find(item => item.value === ${JSON.stringify(id)}).click()`)
|
|
}
|
|
if (process.env.GOODBUDDY_SUPERVISOR_HISTORY) {
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?recap=1')
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
|
for (const width of [1440, 390]) for (const theme of ['light', 'dark']) {
|
|
win.setContentSize(width, 800)
|
|
await wait(`innerWidth === ${width}`)
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
await js('document.querySelector(".supervisor-workspace__result-navigation button").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__history-menu")')
|
|
await settle()
|
|
const report = await js(`(() => {
|
|
const menu = document.querySelector('.supervisor-workspace__history-menu');
|
|
const context = document.querySelector('.supervisor-workspace__review-context');
|
|
const r = menu.getBoundingClientRect();
|
|
return { left:r.left, right:r.right, bottom:r.bottom, top:r.top,
|
|
overflow:menu.scrollWidth > menu.clientWidth + 1 || context.scrollWidth > context.clientWidth + 1,
|
|
options:[...menu.querySelectorAll('[role=menuitemradio]')].map(e => e.textContent),
|
|
duplicate:!!context.querySelector('.supervisor-workspace__review-meta') };
|
|
})()`)
|
|
assert(report.left >= 16 && report.right <= width - 16 && report.top >= 16 && report.bottom <= 784)
|
|
assert(!report.overflow && !report.duplicate)
|
|
assert.equal(report.options.length, 2)
|
|
assert(report.options.every(text => text.includes('生成于') && text.includes('全局') && text.includes('时间范围')))
|
|
await js('document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape",bubbles:true}))')
|
|
reports.push({ width, theme, ...report })
|
|
}
|
|
await selectHistory('older-result')
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
|
await js('document.querySelector("#supervisor-tab-graph").click()')
|
|
assert.equal(await js('document.querySelector(".supervisor-workspace__result-navigation button").value'), 'older-result')
|
|
assert.deepEqual(errors, [])
|
|
console.log(JSON.stringify({ history: 'passed', reports }))
|
|
win.destroy(); app.quit(); return
|
|
}
|
|
const checkSpiral = async () => {
|
|
const report = await js(`(() => {
|
|
const heading = document.querySelector('.story-graph-3d__toolbar');
|
|
const viewport = document.querySelector('.story-graph-3d__viewport');
|
|
const picker = document.querySelector('.story-graph-3d__picker');
|
|
const info = heading.querySelector('button[title="图例"]');
|
|
const h = heading.getBoundingClientRect(), v = viewport.getBoundingClientRect(), p = picker.getBoundingClientRect();
|
|
return {scene:JSON.parse(document.documentElement.dataset.helix),
|
|
levels:heading.parentElement.children.length, headingBottom:h.bottom, canvasTop:v.top,
|
|
pickerInHeading:heading.contains(picker), pickerTop:p.top, headingTop:h.top,
|
|
rootFont:getComputedStyle(heading.querySelector('strong')).fontSize,
|
|
iconOnly:info.textContent === '', infoLabel:info.getAttribute('aria-label'),
|
|
presets:document.querySelectorAll('.story-graph-3d__views,[aria-label="视角"]').length,
|
|
caption:document.querySelectorAll('.story-graph-3d__scale').length};
|
|
})()`)
|
|
assert.equal(report.levels, 2, 'Only heading controls and canvas')
|
|
assert.equal(report.canvasTop, report.headingBottom, 'No inner toolbar row between title and canvas')
|
|
assert(report.pickerInHeading && report.pickerTop >= report.headingTop)
|
|
assert.equal(report.rootFont, '13px')
|
|
assert(report.iconOnly && report.infoLabel === '图例')
|
|
assert.equal(report.presets, 0); assert.equal(report.caption, 0)
|
|
const { timeline, marks, staves } = report.scene
|
|
for (const mark of marks) assert(mark.storyId ? mark.color !== timeline.colors[0] : mark.color === timeline.colors[0], 'Assigned and unassigned marks keep their color semantics')
|
|
assert.equal(timeline.groups.reduce((total, group) => total + group.count, 0), timeline.indices, 'Every face belongs to the single helix')
|
|
assert(timeline.groups.some(group => group.materialIndex === 0), 'Sparse events leave neutral gaps')
|
|
const assignedIntervals = timeline.intervals.filter(interval => interval.event?.storyId)
|
|
if (assignedIntervals.length) assert(timeline.groups.some(group => group.materialIndex > 0), 'Saved intervals produce colored tube faces')
|
|
for (const interval of assignedIntervals) {
|
|
const mark = marks.find(mark => mark.ids.includes(interval.event.id))
|
|
assert(mark && mark.storyId === interval.event.storyId)
|
|
assert(timeline.colors.includes(mark.color), 'Event mark and interval share story palette')
|
|
assert(interval.from >= interval.event.t && interval.to <= interval.event.end)
|
|
}
|
|
for (const group of timeline.groups.filter(group => group.materialIndex > 0)) {
|
|
const covered = assignedIntervals.filter(interval => interval.from < group.to && interval.to > group.from)
|
|
const duration = covered.reduce((sum, interval) => sum + Math.max(0, Math.min(interval.to, group.to) - Math.max(interval.from, group.from)), 0)
|
|
assert(Math.abs(duration - (group.to - group.from)) < 200, 'Actual colored vertices never cover a gap (Float32 geometry tolerance)')
|
|
for (const interval of covered.filter(interval => Math.min(interval.to, group.to) - Math.max(interval.from, group.from) > 200)) {
|
|
assert.equal(timeline.colors[group.materialIndex], marks.find(mark => mark.ids.includes(interval.event.id))?.color)
|
|
}
|
|
}
|
|
for (const stave of staves) for (const mark of marks.filter(mark => mark.storyId === stave.id)) assert.equal(stave.color, mark.color)
|
|
await js(`document.querySelector('.story-graph-3d__toolbar button[title="图例"]').focus(); document.activeElement.click()`)
|
|
await wait('!!document.querySelector(".inline-help__content")')
|
|
assert(await js('document.activeElement.getAttribute("aria-expanded") === "true"'))
|
|
await js('document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape",bubbles:true}))')
|
|
await wait('!document.querySelector(".inline-help__content")')
|
|
assert(await js('document.activeElement.getAttribute("title") === "图例"'), 'Info Escape retains trigger focus')
|
|
return { coloredIntervals: assignedIntervals.length, coloredGroups: timeline.groups.filter(group => group.materialIndex > 0).length,
|
|
marks: marks.length, assignedEvents: marks.filter(mark => mark.storyId).flatMap(mark => mark.ids).length,
|
|
unassignedEvents: marks.filter(mark => !mark.storyId).flatMap(mark => mark.ids).length }
|
|
}
|
|
const capturePage = async () => process.env.GOODBUDDY_SUPERVISOR_NO_SCREENSHOT
|
|
? { toPNG: () => new Uint8Array() }
|
|
: win.webContents.capturePage()
|
|
if (process.env.GOODBUDDY_SUPERVISOR_BACKUP) {
|
|
const resultId = process.env.GOODBUDDY_SUPERVISOR_RESULT
|
|
assert(resultId, 'Specify the historical result to measure')
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?spiral=1&portable=1')
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
|
await js('document.querySelector("#supervisor-tab-graph").click()')
|
|
await selectHistory(resultId)
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false" && document.querySelectorAll(".supervisor-workspace__list-panel button").length === 21')
|
|
await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()')
|
|
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.dots.length === 21')
|
|
await js('document.fonts.ready')
|
|
await settle()
|
|
const geometry = await js('JSON.parse(document.documentElement.dataset.helix)')
|
|
const initialCamera = await js('JSON.parse(document.documentElement.dataset.camera)')
|
|
assert(Math.abs(initialCamera[1] / 900 - Math.sin(0.38)) < 1e-6, 'Default oblique camera')
|
|
const expected = await js(`fetch('/portable-review.json').then(r=>r.json()).then(data=>data.graphs[${JSON.stringify(resultId)}].events.map(e=>e.id))`)
|
|
assert.deepEqual([...geometry.events].sort(), [...expected].sort())
|
|
assert.deepEqual([...geometry.dots].sort(), [...expected].sort(), 'Real event clusters on the helix, not just empty staves')
|
|
assert.equal(geometry.points.length, 2801, 'Actual rings of one continuous tube for point-only history')
|
|
assert(geometry.clusters > 1 && geometry.clusters <= 21, 'Events occupy multiple real time positions after layout')
|
|
const radii = geometry.points.map(point => point[0])
|
|
assert(Math.max(...radii) - Math.min(...radii) > 40)
|
|
for (const [width, theme] of [[1440, 'light'], [1440, 'dark'], [1024, 'light'], [390, 'light'], [390, 'dark']]) {
|
|
win.setContentSize(width, 1000)
|
|
await wait(`innerWidth === ${width} && innerHeight === 1000`)
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
await settle()
|
|
const boxes = await js(`(() => {
|
|
const box = e => { const r=e.getBoundingClientRect(); return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,width:r.width,height:r.height}; };
|
|
const column=document.querySelector('.supervisor-workspace__graph-canvas');
|
|
const selector=document.querySelector('.supervisor-workspace__result-navigation button');
|
|
const toolbar=document.querySelector('.story-graph-3d__toolbar');
|
|
const views=toolbar.querySelector('button[title="图例"]');
|
|
const segment=toolbar.querySelector('.segmented-control');
|
|
const buttons=[...segment.querySelectorAll('button')].map(box);
|
|
return {width:innerWidth,pageWidth:document.documentElement.scrollWidth,column:box(column),selector:box(selector),
|
|
context:box(document.querySelector('.supervisor-workspace__review-context')),toolbar:box(toolbar),views:box(views),
|
|
canvas:box(document.querySelector('.story-graph-3d__gl')),gaps:buttons.slice(1).map((b,i)=>b.left-buttons[i].right),
|
|
segmentPadding:getComputedStyle(segment).paddingTop,
|
|
events:document.querySelectorAll('.supervisor-workspace__list-panel button').length,
|
|
bounds:JSON.parse(document.documentElement.dataset.sceneBounds),
|
|
scene:JSON.parse(document.documentElement.dataset.helix).dots.length,
|
|
clusters:JSON.parse(document.documentElement.dataset.helix).clusters};
|
|
})()`)
|
|
assert(boxes.selector.top >= boxes.context.top && boxes.selector.bottom <= boxes.context.bottom, 'History inside shared context')
|
|
assert(boxes.context.bottom <= boxes.column.top, 'Review context above graph columns')
|
|
assert(boxes.gaps.every(gap => gap >= 2), 'Shared camera segment gaps')
|
|
assert.equal(boxes.segmentPadding, '3px')
|
|
assert(boxes.views.right <= boxes.column.right - 16 && boxes.pageWidth <= width, `Controls fit without overflow: ${JSON.stringify(boxes)}`)
|
|
assert(boxes.canvas.width > 0 && boxes.canvas.height > 0)
|
|
assert.equal(boxes.events, 21); assert.equal(boxes.scene, 21)
|
|
assert(Object.values(boxes.bounds).every(value => Math.abs(value) < 1), 'All root scene vertices fit within the camera viewport')
|
|
reports.push({ theme, ...boxes, colors: await checkSpiral() })
|
|
if (width === 1440 && !process.env.GOODBUDDY_SUPERVISOR_NO_SCREENSHOT) await writeFile(join(artifacts, 'portable-spiral.png'), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
await js('document.querySelector(".supervisor-workspace__list-panel button").click()')
|
|
await wait('document.querySelector(".supervisor-workspace__list-panel button")?.getAttribute("aria-pressed") === "true"')
|
|
// The pre-assignment case uses the same real events with an explicitly empty membership response.
|
|
await js(`window.goodbuddy.supervision.stories = async () => ({stories:[],experiences:[],unassigned:21,canUndo:false}); document.querySelector('.supervisor-workspace__review-context > .secondary-button').click()`)
|
|
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.dots.length === 21 && document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
|
await js('document.querySelector(".story-graph-3d__picker").click()')
|
|
await wait('document.querySelectorAll(".story-graph-3d__menu [role=menuitem]").length === 1')
|
|
await js('document.querySelector(".story-graph-3d__menu [role=menuitem]").click()')
|
|
await wait('document.querySelector(".story-graph-3d__picker")?.textContent.includes("未归属事件")')
|
|
assert.equal(await js('JSON.parse(document.documentElement.dataset.helix).dots.length'), 21)
|
|
assert(await js('JSON.parse(document.documentElement.dataset.helix).marks.every(mark => !mark.storyId)'), 'Empty membership keeps neutral event marks')
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(join(artifacts, 'portable-measurements.json'), JSON.stringify({ resultId, reports, clusters: geometry.clusters, radiusRange: [Math.min(...radii), Math.max(...radii)], errors }, null, 2))
|
|
console.log(JSON.stringify({ resultId, reports, clusters: geometry.clusters, radiusRange: [Math.min(...radii), Math.max(...radii)], emptyMembershipEvents: 21, errors }))
|
|
win.destroy(); app.quit(); return
|
|
}
|
|
if (process.env.GOODBUDDY_SUPERVISOR_SPIRAL) {
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?spiral=1&recap=1')
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
|
await js('document.querySelector("#supervisor-tab-graph").click()')
|
|
await wait('[...document.querySelectorAll("button")].some(b => b.textContent === "时间螺旋")')
|
|
await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()')
|
|
await wait('JSON.parse(document.documentElement.dataset.helix || "null")?.events.length === 8')
|
|
const geometry = await js('JSON.parse(document.documentElement.dataset.helix)')
|
|
const palettes = new Map()
|
|
assert(new Set(geometry.timeline.groups.filter(group => group.materialIndex > 0).map(group => group.materialIndex)).size >= 2, 'Different primary stories paint distinct interval materials in this fixture')
|
|
assert(geometry.points.length >= 2801, 'One continuous helix with exact interval boundaries')
|
|
const radii = geometry.points.map(p => p[0])
|
|
assert(Math.max(...radii) - Math.min(...radii) > 40, 'Actual rendered geometry reflects uneven attention')
|
|
geometry.points.forEach((point, i) => { if (i) assert(point[1] > geometry.points[i - 1][1], 'Actual tube advances monotonically in time') })
|
|
assert(!geometry.events.includes('outside-review'), 'Aggregate story events outside this review are not plotted')
|
|
for (const [width, theme] of [[1440, 'light'], [1440, 'dark'], [1024, 'light'], [390, 'light'], [390, 'dark']]) {
|
|
win.setContentSize(width, 1000)
|
|
await wait(`innerWidth === ${width} && innerHeight === 1000`)
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
await settle()
|
|
const boxes = await js(`(() => {
|
|
const selector = document.querySelector('.supervisor-workspace__result-navigation button');
|
|
const r = selector.getBoundingClientRect(), gl = document.querySelector('.story-graph-3d__gl').getBoundingClientRect();
|
|
const toolbar = document.querySelector('.story-graph-3d__toolbar').getBoundingClientRect();
|
|
const breadcrumb = document.querySelector('.story-graph-3d__breadcrumb');
|
|
const context = document.querySelector('.supervisor-workspace__review-context').getBoundingClientRect();
|
|
return { value: selector.value, left: r.left, right: r.right, top: r.top, bottom: r.bottom,
|
|
toolbar: { left: toolbar.left, top: toolbar.top, bottom: toolbar.bottom },
|
|
atRoot: breadcrumb.textContent === '全部', context: {top:context.top,bottom:context.bottom},
|
|
pickers: document.querySelectorAll('.story-graph-3d__picker').length,
|
|
headingSelectors: document.querySelectorAll('.supervisor-workspace__canvas-heading select').length,
|
|
selectors: document.querySelectorAll('.supervisor-workspace__result-navigation button').length,
|
|
width: innerWidth, canvasWidth: gl.width, canvasHeight: gl.height, pageWidth: document.documentElement.scrollWidth };
|
|
})()`)
|
|
assert.equal(boxes.value, 'fixture-result')
|
|
assert.equal(boxes.selectors, 1)
|
|
assert.equal(boxes.headingSelectors, 0)
|
|
assert(boxes.atRoot, 'Root All breadcrumb stays independent of review history')
|
|
assert.equal(boxes.pickers, 1, 'Only the hierarchy drill picker remains')
|
|
assert(boxes.top >= boxes.context.top && boxes.bottom <= boxes.context.bottom, 'History is inside shared review context')
|
|
assert(boxes.context.bottom <= boxes.toolbar.top, 'Context precedes graph tools')
|
|
assert(boxes.left >= 0 && boxes.right <= boxes.width && boxes.pageWidth <= boxes.width, 'History selector fits without horizontal overflow')
|
|
assert(boxes.canvasWidth > 0 && boxes.canvasHeight > 0, 'WebGL canvas is laid out')
|
|
assert((await checkSpiral()).coloredGroups > 0, 'Interval fixture paints the actual tube in both themes and all sizes')
|
|
palettes.set(theme, await js('JSON.parse(document.documentElement.dataset.helix).timeline.colors'))
|
|
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.getAttribute("aria-pressed") === "false").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__map")')
|
|
assert.equal(await js('getComputedStyle(document.querySelector(".supervisor-workspace__canvas-heading > strong")).fontSize'), '13px', 'Flat and spiral All use identical typography')
|
|
await js('document.querySelector(".supervisor-workspace__canvas-heading button[title=图例]").click()')
|
|
await wait('!!document.querySelector(".inline-help__content")')
|
|
await js('window.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape",bubbles:true}))')
|
|
await wait('!document.querySelector(".inline-help__content")')
|
|
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.textContent === "时间螺旋").click()')
|
|
await wait('!!document.querySelector(".story-graph-3d__gl")')
|
|
await js('document.querySelector("#supervisor-tab-overview").click(); document.querySelector(".page-shell").scrollTop = 0')
|
|
await settle()
|
|
const overview = await js(`(() => { const r = document.querySelector('.supervisor-workspace__review-context').getBoundingClientRect();
|
|
return {top:r.top,bottom:r.bottom,forms:document.querySelectorAll('.supervisor-workspace__toolbar').length}; })()`)
|
|
assert.equal(overview.top, boxes.context.top, 'Review bar keeps its top across tabs')
|
|
assert.equal(overview.bottom, boxes.context.bottom, 'Review bar keeps its height across tabs')
|
|
assert.equal(overview.forms, 0, 'New review inputs collapsed by default')
|
|
await js('document.querySelector("#supervisor-tab-graph").click()')
|
|
await wait('!!document.querySelector(".story-graph-3d__gl")')
|
|
}
|
|
assert.notDeepEqual(palettes.get('light'), palettes.get('dark'), 'WebGL palette follows theme tokens')
|
|
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.textContent === "时间螺旋").click()')
|
|
await wait('!!document.querySelector(".story-graph-3d__gl")')
|
|
const cameraBefore = await js('document.documentElement.dataset.camera')
|
|
await js('document.querySelector(".story-graph-3d__viewport canvas[tabindex]").dispatchEvent(new KeyboardEvent("keydown", {key:"ArrowLeft",bubbles:true}))')
|
|
await wait(`document.documentElement.dataset.camera !== ${JSON.stringify(cameraBefore)}`)
|
|
win.show(); win.focus(); win.webContents.focus()
|
|
await wait('document.hasFocus()')
|
|
await js('document.querySelector(".story-graph-3d__toolbar button[title=图例]").focus()')
|
|
win.webContents.sendInputEvent({type:'keyDown', keyCode:'Space'})
|
|
win.webContents.sendInputEvent({type:'keyUp', keyCode:'Space'})
|
|
await wait('document.activeElement.getAttribute("aria-expanded") === "true"')
|
|
win.webContents.sendInputEvent({type:'keyDown', keyCode:'Escape'})
|
|
win.webContents.sendInputEvent({type:'keyUp', keyCode:'Escape'})
|
|
await wait('!document.querySelector(".inline-help__content")')
|
|
const dragAt = await js(`(() => { const c = document.querySelector('.story-graph-3d__viewport canvas[tabindex]'); c.scrollIntoView();
|
|
const r = c.getBoundingClientRect(); return {x:Math.round(r.left+r.width/2), y:Math.round(r.top+r.height/2)}; })()`)
|
|
const beforeDrag = await js('document.documentElement.dataset.camera')
|
|
win.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,...dragAt})
|
|
win.webContents.sendInputEvent({type:'mouseMove',button:'left',modifiers:['leftButtonDown'],x:dragAt.x+30,y:dragAt.y+12})
|
|
win.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,x:dragAt.x+30,y:dragAt.y+12})
|
|
await wait(`document.documentElement.dataset.camera !== ${JSON.stringify(beforeDrag)}`)
|
|
win.setContentSize(1024, 900)
|
|
await settle()
|
|
assert(await js('!document.querySelector(".supervisor-workspace__detail").getClientRects().length'), 'Medium details closed by default')
|
|
await js('document.querySelector(".supervisor-workspace__list-panel button").click()')
|
|
await wait('document.activeElement === document.querySelector(".supervisor-workspace__detail")')
|
|
assert(await js(`(() => { const panel = document.querySelector('.supervisor-workspace__detail').getBoundingClientRect();
|
|
const graph = document.querySelector('.supervisor-workspace__graph-layout').getBoundingClientRect();
|
|
return panel.top >= graph.top && panel.bottom <= graph.bottom + 1; })()`), 'Medium details stay inside graph row')
|
|
await js('document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {key:"Escape",bubbles:true}))')
|
|
await wait('!document.querySelector(".supervisor-workspace__detail").getClientRects().length')
|
|
assert(await js('document.activeElement.matches(".supervisor-workspace__detail-toggle")'), 'Details restore toggle focus')
|
|
win.setContentSize(1440, 700)
|
|
await settle()
|
|
assert(await js('document.querySelector(".supervisor-workspace__graph-layout").getBoundingClientRect().height < 660'), 'Short spiral does not inherit flat graph minimum')
|
|
for (const name of ['Fixture project', 'Fixture story']) {
|
|
await js('document.querySelector(".story-graph-3d__picker").click()')
|
|
await wait('!!document.querySelector(".story-graph-3d__menu [role=menuitem]")')
|
|
await js('document.activeElement.dispatchEvent(new KeyboardEvent("keydown", {key:"End",bubbles:true}))')
|
|
assert(await js('document.activeElement === [...document.querySelectorAll(".story-graph-3d__menu [role=menuitem]")].at(-1)'), 'Hierarchy menu keyboard End')
|
|
await js(`([...document.querySelectorAll('.story-graph-3d__menu [role=menuitem]')].find(e => e.textContent.includes('${name}'))).click()`)
|
|
await wait(`document.querySelector('.story-graph-3d__breadcrumb strong')?.textContent === '${name}'`)
|
|
if (name === 'Fixture project') await checkSpiral()
|
|
assert.equal(await js('document.querySelectorAll(".story-graph-3d__breadcrumb select").length'), 0)
|
|
}
|
|
await js('[...document.querySelectorAll(".story-graph-3d__breadcrumb button")].find(b => b.textContent === "Fixture project").click()')
|
|
await wait('document.querySelector(".story-graph-3d__breadcrumb strong")?.textContent === "Fixture project"')
|
|
await js('document.querySelector(".story-graph-3d__breadcrumb button").click()')
|
|
await wait('document.querySelector(".story-graph-3d__breadcrumb strong")?.textContent === "全部"')
|
|
assert.equal(await js('document.querySelectorAll(".story-graph-3d__breadcrumb > span").length'), 1)
|
|
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.getAttribute("aria-pressed") === "false").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__map")')
|
|
assert.equal(await js('document.querySelector(".supervisor-workspace__result-navigation button").value'), 'fixture-result')
|
|
assert.equal(await js('document.querySelectorAll(".supervisor-workspace__result-navigation button").length'), 1)
|
|
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b => b.getAttribute("aria-pressed") === "false").click()')
|
|
await wait('!!document.querySelector(".story-graph-3d__gl")')
|
|
await selectHistory('older-result')
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false" && JSON.parse(document.documentElement.dataset.helix || "null")?.events.length === 1')
|
|
await js('document.querySelector("#supervisor-tab-overview").click()')
|
|
await wait('document.querySelector(".supervisor-workspace__result-navigation button")?.value === "older-result"')
|
|
assert.deepEqual(errors, [])
|
|
console.log(JSON.stringify({ spiral: 'passed', vertices: geometry.points.length, radiusRange: [Math.min(...radii), Math.max(...radii)], widths: [1440, 390], errors }))
|
|
win.destroy(); app.quit(); return
|
|
}
|
|
const checkDiscussion = async () => {
|
|
const report = await js(`(() => {
|
|
const editor = document.querySelector('.supervision-discussion-editor');
|
|
const box = e => { const r = e.getBoundingClientRect(); return {left:r.left, right:r.right, top:r.top, bottom:r.bottom}; };
|
|
const target = editor.querySelector('.supervision-discussion-editor__target');
|
|
const field = editor.querySelector('.field'), label = field.querySelector('span'), input = field.querySelector('textarea');
|
|
const footer = editor.querySelector('footer');
|
|
return { target:box(target), field:box(field), label:box(label), input:box(input), footer:box(footer),
|
|
buttons:[...footer.querySelectorAll('button')].map(box), align:getComputedStyle(footer).justifyContent,
|
|
clientWidth:editor.clientWidth, scrollWidth:editor.scrollWidth, value:input.value,
|
|
border:getComputedStyle(target).borderBottomWidth, disclosures:editor.querySelectorAll('details').length };
|
|
})()`)
|
|
assert(report.value.includes('本周已核对交付清单') && report.value.includes('原始依据:模拟会议记录'), 'Actual reference content is editable')
|
|
assert.equal(report.field.top - report.target.bottom, 16, 'Target to message spacing')
|
|
assert.equal(report.input.top - report.label.bottom, 8, 'Label to input spacing')
|
|
assert.equal(report.footer.top - report.field.bottom, 16, 'Message to footer spacing')
|
|
assert.equal(report.border, '1px', 'Destination is separated')
|
|
assert.equal(report.align, 'flex-end', 'Shared right aligned footer')
|
|
assert(Math.abs(report.buttons[1].right - report.footer.right) < 1, 'Send at right edge')
|
|
assert(report.buttons[0].right < report.buttons[1].left && report.buttons[0].top === report.buttons[1].top, 'Cancel then send')
|
|
assert(report.scrollWidth <= report.clientWidth && report.disclosures === 0, 'No overflow or redundant disclosure')
|
|
return report
|
|
}
|
|
if (process.env.GOODBUDDY_SUPERVISOR_SETTINGS) {
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL)
|
|
await wait('!!document.querySelector("#supervisor-tab-settings")')
|
|
await js('document.querySelector("#supervisor-tab-settings").click()')
|
|
await wait('!!document.querySelector(".supervisor-settings")')
|
|
await js('document.fonts.ready')
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const [width, height] of [[1440, 1100], [1024, 768], [390, 480]]) {
|
|
win.setContentSize(width, height)
|
|
for (const tab of ['model', 'review', 'stories', 'suggestions']) {
|
|
await js(`document.querySelector('#supervisor-settings-tab-${tab}').click(); document.querySelector('.page-shell').scrollTop = 0`)
|
|
await wait(`document.querySelector('#supervisor-settings-tab-${tab}').getAttribute('aria-selected') === 'true' && innerWidth === ${width}`)
|
|
await settle()
|
|
const report = await js(`(() => {
|
|
const root = document.querySelector('.supervisor-settings');
|
|
const nav = root.querySelector('.page-tabs');
|
|
const panel = root.querySelector('[role=tabpanel]:not([hidden])');
|
|
const form = panel.querySelector('form');
|
|
const box = e => { const r = e.getBoundingClientRect(); return {left:r.left, right:r.right, top:r.top, bottom:r.bottom, height:r.height}; };
|
|
return { root:box(root), nav:box(nav), form:box(form), heading:form.querySelector('h2').textContent,
|
|
primaryTabs:document.querySelectorAll('.heartbeat-center > .page-tabs [role=tab]').length,
|
|
settingsTabs:nav.querySelectorAll('[role=tab]').length,
|
|
visiblePanels:root.querySelectorAll('[role=tabpanel]:not([hidden])').length,
|
|
navFlex:getComputedStyle(nav).flexShrink, rootFlex:getComputedStyle(root).flexShrink,
|
|
formBorder:getComputedStyle(form).borderTopWidth, formBackground:getComputedStyle(form).backgroundColor,
|
|
pageWidth:document.documentElement.scrollWidth, rootWidth:root.clientWidth, rootScrollWidth:root.scrollWidth,
|
|
controls:[...form.querySelectorAll('input,select,button')].map(box) };
|
|
})()`)
|
|
assert.equal(report.primaryTabs, 5)
|
|
assert.equal(report.settingsTabs, 4)
|
|
assert.equal(report.visiblePanels, 1)
|
|
assert.equal(report.nav.top - report.root.top, 17, 'Tabs inset from the card border')
|
|
assert.equal(report.form.top - report.nav.bottom, 24, 'Tabs separated from section content')
|
|
assert.equal(report.form.left, report.nav.left, 'Section and tabs share the content edge')
|
|
assert.equal(report.navFlex, '0')
|
|
assert.equal(report.rootFlex, '0')
|
|
assert(report.nav.height >= 36, 'Short windows do not compress tabs')
|
|
assert.equal(report.formBorder, '0px', 'No nested form border')
|
|
assert.equal(report.formBackground, 'rgba(0, 0, 0, 0)', 'One settings surface')
|
|
assert(report.pageWidth <= width && report.rootScrollWidth <= report.rootWidth, 'No horizontal page or card overflow')
|
|
assert(report.controls.every(control => control.left >= report.form.left && control.right <= report.form.right + 1), 'Fields fit the section')
|
|
if (tab === 'model') assert.equal(report.heading, '模型与运行限制')
|
|
reports.push({ theme, width, height, tab, ...report })
|
|
await writeFile(join(artifacts, `settings-${theme}-${width}-${tab}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(join(artifacts, 'settings-measurements.json'), JSON.stringify(reports, null, 2))
|
|
console.log(JSON.stringify({ settingsCases: reports.length, tabInset: 17, contentGap: 24, errors }))
|
|
win.destroy()
|
|
app.quit()
|
|
return
|
|
}
|
|
if (process.env.GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT) {
|
|
win.show()
|
|
const controlsOnly = process.env.GOODBUDDY_SUPERVISOR_CONTENT_LAYOUT === 'controls'
|
|
for (const view of controlsOnly ? ['activity', 'pending'] : ['recap', 'empty', 'activity', 'pending', 'failure']) {
|
|
console.log(`Checking content layout: ${view}`)
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (view === 'activity' ? '?activity=1' : '?recap=1&long-summary=1' + (view === 'failure' ? '&fail-run=1' : view === 'empty' ? '&state=empty' : '')))
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
|
if (view === 'activity') {
|
|
await js('document.querySelector("#supervisor-tab-activity").click()')
|
|
await wait('document.querySelectorAll(".supervisor-activity__item").length === 3')
|
|
} else if (view === 'pending' || view === 'failure') {
|
|
await js('document.querySelector(".supervisor-workspace__review-context .primary-button").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__toolbar .primary-button")')
|
|
await js('document.querySelector(".supervisor-workspace__toolbar .primary-button").click()')
|
|
await wait(`document.documentElement.dataset.reviewNoticeTone === '${view === 'pending' ? 'info' : 'error'}'`)
|
|
}
|
|
await js('document.fonts.ready')
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const width of controlsOnly ? [1440, 1024, 390] : [2000, 1440, 1024, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
await js('document.querySelector(".page-shell").scrollTop = 0')
|
|
await settle()
|
|
const report = await js(`(() => {
|
|
const box = selector => { const r = document.querySelector(selector)?.getBoundingClientRect(); return r && { left:r.left, right:r.right, width:r.width, height:r.height, top:r.top, bottom:r.bottom }; };
|
|
return { width:innerWidth, pageWidth:document.documentElement.scrollWidth,
|
|
panel:box('.heartbeat-center > [role=tabpanel]:not([hidden])'), toolbar:box('.supervisor-workspace__review-context'), recap:box('.supervisor-workspace__recap'),
|
|
prose:box('.supervisor-workspace__prose'), activity:box('.supervisor-activity'),
|
|
readingColumns:document.querySelector('.supervisor-workspace__recap') && getComputedStyle(document.querySelector('.supervisor-workspace__recap')).gridTemplateColumns.split(' ').map(parseFloat),
|
|
sidebar:box('.supervisor-workspace__recap-sidebar'),
|
|
steps:box('.supervisor-activity__steps'),
|
|
empty:box('.supervisor-workspace > .empty-state'), emptyTitle:box('.supervisor-workspace > .empty-state strong'),
|
|
stageConnectors:[...document.querySelectorAll('.supervisor-activity__steps li:not(:last-child)')].map(e=>getComputedStyle(e,'::after').borderTopWidth),
|
|
summaryLength:[...document.querySelectorAll('.supervisor-workspace__summary')].map(e=>e.textContent).join('').length,
|
|
tail:document.querySelector('.supervisor-workspace__prose')?.textContent.includes('长摘要末尾'),
|
|
controls:[...document.querySelectorAll('.supervisor-activity > .supervisor-workspace__action-bar > *')].map(e=>{const r=e.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top};}),
|
|
toolbarControls:[...document.querySelectorAll('.supervisor-workspace__toolbar select, .supervisor-workspace__toolbar button')].map(e=>({disabled:e.disabled,text:e.textContent,top:e.getBoundingClientRect().top,bottom:e.getBoundingClientRect().bottom})),
|
|
notice:box('.supervisor-workspace__run-status'),
|
|
cancel:box('.supervisor-activity__item .danger-ghost') };
|
|
})()`)
|
|
assert(report.pageWidth <= width, 'No page overflow')
|
|
if (view === 'empty') {
|
|
assert(Math.abs(report.empty.width - report.panel.width) < 1, 'Empty state fills panel');
|
|
assert(Math.abs((report.emptyTitle.left + report.emptyTitle.right) / 2 - (report.panel.left + report.panel.right) / 2) < 1, 'Empty state title centered');
|
|
assert(report.empty.top >= report.toolbar.bottom, 'Empty state below toolbar');
|
|
} else if (view !== 'activity') {
|
|
assert(report.summaryLength > 2000 && report.tail, 'Long summary retained')
|
|
assert(Math.abs(report.recap.width - report.panel.width) < 1, 'Recap fills the page panel')
|
|
if (width >= 1024) assert(Math.abs(report.readingColumns[0] / report.readingColumns[1] - 2) < 0.02, 'Overview uses a 2:1 reading layout')
|
|
else assert(report.sidebar.top >= report.prose.bottom, 'Narrow overview puts supporting content after summary')
|
|
for (const control of [report.toolbar]) {
|
|
assert(Math.abs(control.left - report.recap.left) < 1 && Math.abs(control.right - report.recap.right) < 1, 'Toolbar, history and result share edges')
|
|
}
|
|
assert(report.prose.left - report.recap.left <= 25, 'No separately centered inner body')
|
|
if (view === 'pending') {
|
|
assert(report.toolbarControls.every(control => !control.disabled), 'Long review keeps toolbar enabled')
|
|
assert.equal(report.toolbarControls[2].text, '回顾', 'Start name remains stable')
|
|
assert.equal(report.notice, undefined, 'Operation feedback does not occupy workspace layout')
|
|
if (width >= 1024) assert(Math.max(...report.toolbarControls.map(control => control.top)) - Math.min(...report.toolbarControls.map(control => control.top)) <= 4, 'Toolbar stays on one aligned row')
|
|
}
|
|
} else {
|
|
assert(Math.abs(report.activity.width - report.panel.width) < 1, 'Activity fills the page panel')
|
|
assert(report.cancel.width >= 28 && report.cancel.right <= report.panel.right, 'Cancel fits activity panel')
|
|
if (width >= 1024) {
|
|
assert(report.steps.height < 40, 'Compact stage row')
|
|
assert(report.stageConnectors.every(value => value === '1px'), 'Connected stages')
|
|
assert(report.controls[2].left - report.controls[1].right <= 24, 'Refresh grouped with filter')
|
|
}
|
|
}
|
|
reports.push({ view, theme, ...report })
|
|
await writeFile(join(artifacts, `${view}-${theme}-${width}.png`), (await capturePage()).toPNG())
|
|
if (view === 'recap') {
|
|
await js('document.querySelector(".supervisor-workspace__prose").lastElementChild.scrollIntoView({block:"end"})')
|
|
await settle()
|
|
await writeFile(join(artifacts, `${view}-tail-${theme}-${width}.png`), (await capturePage()).toPNG())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(join(artifacts, 'content-layout-measurements.json'), JSON.stringify(reports, null, 2))
|
|
console.log(JSON.stringify({ artifacts, cases: reports.length, errors }))
|
|
win.destroy()
|
|
app.quit()
|
|
return
|
|
}
|
|
if (process.env.GOODBUDDY_SUPERVISOR_ACTIVITY) {
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?activity=1')
|
|
await wait('!!document.querySelector("#supervisor-tab-activity")')
|
|
await js('document.querySelector("#supervisor-tab-activity").click()')
|
|
await wait('document.querySelectorAll(".supervisor-activity__item").length === 3')
|
|
await js('document.fonts.ready')
|
|
win.show()
|
|
win.focus()
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const width of [1440, 390]) {
|
|
win.setContentSize(width, 1000)
|
|
await js('document.querySelectorAll(".supervisor-activity__item details").forEach(e => e.open = false); document.querySelector(".page-shell").scrollTop = 0')
|
|
await settle()
|
|
const report = await js(`(() => {
|
|
const root = document.querySelector('.supervisor-activity');
|
|
const item = root.querySelector('.supervisor-activity__item');
|
|
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth, clientWidth: root.clientWidth, scrollWidth: root.scrollWidth,
|
|
itemHeight: item.getBoundingClientRect().height,
|
|
states: [...item.querySelectorAll('[data-phase]')].map(e => e.dataset.state),
|
|
diagnosticOpen: item.querySelector('.supervisor-activity__diagnostics').open,
|
|
advancedOpen: item.querySelector('.supervisor-activity__advanced').open };
|
|
})()`)
|
|
assert(report.pageWidth <= width && report.scrollWidth <= report.clientWidth, 'Activity overflow')
|
|
assert.deepEqual(report.states, ['completed', 'completed', 'failed', 'pending'])
|
|
assert(!report.diagnosticOpen && !report.advancedOpen, 'Details collapsed by default')
|
|
assert(report.itemHeight < (width === 390 ? 650 : 440), 'Compact run summary')
|
|
reports.push({ theme, ...report })
|
|
await writeFile(join(artifacts, `review-activity-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
await js('document.querySelector(".supervisor-activity__diagnostics summary").focus()')
|
|
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Enter' })
|
|
win.webContents.sendInputEvent({ type: 'char', keyCode: '\r' })
|
|
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Enter' })
|
|
await settle()
|
|
assert(await js('document.querySelector(".supervisor-activity__diagnostics").open'), 'Native keyboard disclosure')
|
|
const error = await js(`(() => { const e = document.querySelector('.supervisor-activity__diagnostics pre'); return { height: e.clientHeight, scrollHeight: e.scrollHeight, selectable: getComputedStyle(e).userSelect, text: e.textContent }; })()`)
|
|
assert(error.height <= 240 && error.scrollHeight > error.height && error.selectable === 'text' && error.text.includes('relations'), 'Full bounded selectable error')
|
|
await js('document.querySelector(".supervisor-activity__diagnostics").scrollIntoView({block:"center"})')
|
|
await settle()
|
|
await writeFile(join(artifacts, `review-error-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
await js('document.querySelector(".supervisor-activity__diagnostics").open = false; document.querySelector(".supervisor-activity__item button[aria-expanded=false]")?.click()')
|
|
await wait('!!document.querySelector(".supervisor-activity__batch")')
|
|
await js('document.querySelectorAll(".supervisor-activity__project, .supervisor-activity__conversation").forEach(e => e.open = true); document.querySelector(".supervisor-activity__tree").scrollIntoView({block:"start"})')
|
|
await settle()
|
|
assert(await js('document.querySelectorAll(".supervisor-activity__batch").length === 6'), 'All six saved leaves')
|
|
assert(await js('document.documentElement.scrollWidth <= innerWidth'), 'Hierarchy overflow')
|
|
await writeFile(join(artifacts, `review-tree-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
await js('document.querySelector(".supervisor-activity__item button[aria-expanded=true]").click()')
|
|
}
|
|
}
|
|
await js('document.querySelectorAll(".supervisor-activity__item")[1].querySelector("button[aria-expanded]").click()')
|
|
await wait('document.querySelectorAll(".supervisor-activity__batch").length === 10')
|
|
await js('[...document.querySelectorAll(".supervisor-activity__tree button")].find(e => e.textContent === "下一页").click()')
|
|
await wait('document.querySelectorAll(".supervisor-activity__batch").length === 2')
|
|
assert(await js('[...document.querySelectorAll(".supervisor-activity__tree button")].find(e => e.textContent === "下一页").disabled'), 'Paging ends at persisted batch count')
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(join(artifacts, 'review-activity-measurements.json'), JSON.stringify(reports, null, 2))
|
|
console.log(JSON.stringify({ artifacts, cases: reports.length, errors }))
|
|
win.destroy()
|
|
app.quit()
|
|
return
|
|
}
|
|
if (process.env.GOODBUDDY_SUPERVISOR_RECAP) {
|
|
for (const scenario of ['populated', 'automatic-empty', 'empty']) {
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?recap=1' + (scenario === 'populated' ? '&menu=1' : scenario === 'empty' ? '&state=empty' : ''))
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
|
await js('document.fonts.ready')
|
|
for (const width of [1440, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
for (const tab of ['overview', 'plans']) {
|
|
await js(`document.querySelector('#supervisor-tab-${tab}').click(); document.querySelector('.page-shell').scrollTop = 0`)
|
|
await settle()
|
|
const report = await js(`(() => {
|
|
const panel = document.querySelector('#supervisor-panel-${tab}');
|
|
const recap = panel.querySelector('.supervisor-workspace__recap');
|
|
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth,
|
|
clientWidth: panel.clientWidth, scrollWidth: panel.scrollWidth,
|
|
reports: !!panel.querySelector('#heartbeat-reports-title'),
|
|
suggestions: !!panel.querySelector('#supervision-suggestions-title'),
|
|
refresh: [...panel.querySelectorAll('button')].filter(b => b.getClientRects().length && b.textContent.includes('刷新')).length,
|
|
recap: !!recap && !!recap.getClientRects().length,
|
|
paragraphs: recap?.querySelectorAll('.supervisor-workspace__summary').length ?? 0,
|
|
proseWidth: recap?.querySelector('.supervisor-workspace__prose').getBoundingClientRect().width,
|
|
text: panel.innerText };
|
|
})()`)
|
|
assert(report.pageWidth <= width && report.scrollWidth <= report.clientWidth, 'Recap/automatic overflow')
|
|
assert.equal(report.refresh, 1, 'One refresh owner per panel')
|
|
assert.equal(report.reports, tab === 'plans' && scenario === 'populated')
|
|
assert.equal(report.suggestions, tab === 'plans' && scenario === 'populated')
|
|
assert.equal(report.recap, tab === 'overview' && scenario !== 'empty')
|
|
if (tab === 'overview') {
|
|
assert(!report.text.includes('暂无历史心跳报告') && !report.text.includes('待确认记忆') && !report.text.includes('监督建议'))
|
|
if (scenario !== 'empty') {
|
|
assert.equal(report.paragraphs, 4)
|
|
assert(report.text.includes('外部评审时间') && report.text.includes('未解决事项'))
|
|
assert(report.proseWidth > report.clientWidth * (width > 1000 ? 0.55 : 0.8), 'Prose uses the primary reading column')
|
|
} else assert(report.text.includes('还没有成功回顾'))
|
|
} else {
|
|
assert(!report.text.includes('暂无历史心跳报告'))
|
|
assert.equal(report.text.includes('历史心跳报告'), scenario === 'populated')
|
|
}
|
|
reports.push({ scenario, tab, ...report })
|
|
const prefix = `recap-${scenario}-${tab}-${width}`
|
|
await writeFile(join(artifacts, `${prefix}.png`), (await win.webContents.capturePage()).toPNG())
|
|
if (tab === 'plans' && scenario === 'populated') {
|
|
for (const anchor of ['supervision-suggestions-title', 'heartbeat-reports-title']) {
|
|
await js(`document.getElementById('${anchor}').scrollIntoView({block:'start'})`)
|
|
if (anchor === 'heartbeat-reports-title' && scenario === 'populated') await js(`document.querySelector('#heartbeat-panel-history button[aria-expanded=false]')?.click()`)
|
|
await settle()
|
|
await writeFile(join(artifacts, `${prefix}-${anchor}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (scenario === 'empty') {
|
|
// Without a graph, the empty state fills the visible height and its message sits in the middle.
|
|
for (const [width, height] of [[1440, 900], [390, 800]]) {
|
|
win.setContentSize(width, height)
|
|
await js(`document.querySelector('#supervisor-tab-graph').click(); document.querySelector('.page-shell').scrollTop = 0`)
|
|
await wait('!!document.querySelector(".supervisor-workspace__graph-empty .empty-state strong")')
|
|
await settle()
|
|
const empty = await js(`(() => {
|
|
const box = document.querySelector('.supervisor-workspace__graph-empty');
|
|
const shell = document.querySelector('.page-shell');
|
|
const title = box.querySelector('.empty-state strong').getBoundingClientRect();
|
|
const r = box.getBoundingClientRect(); const s = shell.getBoundingClientRect();
|
|
return { boxCenterX: r.left + r.width / 2, titleCenterX: title.left + title.width / 2,
|
|
boxTop: r.top, boxBottom: r.bottom, titleTop: title.top, shellBottom: s.bottom,
|
|
pageScroll: shell.scrollHeight > shell.clientHeight + 1,
|
|
buttons: [...box.querySelectorAll('button')].map(b => b.textContent),
|
|
outsideRefresh: [...document.querySelectorAll('#supervisor-panel-graph button')].filter(b => !box.contains(b) && b.textContent.includes('刷新')).length };
|
|
})()`)
|
|
assert(Math.abs(empty.boxCenterX - empty.titleCenterX) <= 2, 'Graph empty state is horizontally centred')
|
|
const middle = (empty.boxTop + empty.boxBottom) / 2
|
|
assert(empty.titleTop > empty.boxTop + 60 && Math.abs(empty.titleTop - middle) < (empty.boxBottom - empty.boxTop) / 4, 'Graph empty state is vertically centred')
|
|
assert(empty.shellBottom - empty.boxBottom <= 48, 'Graph empty state reaches the bottom margin')
|
|
assert.equal(empty.pageScroll, false, 'Graph empty state does not scroll the page')
|
|
assert.deepEqual(empty.buttons, ['工作回顾'])
|
|
assert.equal(empty.outsideRefresh, 1)
|
|
reports.push({ scenario: 'graph-empty', width, height, ...empty })
|
|
await writeFile(join(artifacts, `graph-empty-${width}x${height}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
if (scenario === 'populated') {
|
|
await js('document.querySelector("#supervisor-tab-overview").click()')
|
|
await js('document.querySelector(".supervisor-workspace__review-context .primary-button").click()')
|
|
// Toolbar: incremental review button, refresh, and the more menu holding re-analysis.
|
|
const toolbar = await js(`(() => {
|
|
const bar = document.querySelector('.supervisor-workspace__toolbar');
|
|
const rect = bar.getBoundingClientRect();
|
|
const more = bar.querySelector('[aria-haspopup=menu]');
|
|
// Rows are groups of items whose vertical ranges overlap.
|
|
const items = [...bar.children].filter(el => el.getClientRects().length).map(el => el.getBoundingClientRect()).sort((a, b) => a.top - b.top);
|
|
const tops = items.reduce((rows, rect) => { const last = rows.at(-1); if (last && rect.top < last.bottom) last.bottom = Math.max(last.bottom, rect.bottom); else rows.push({ top: Math.round(rect.top), bottom: rect.bottom }); return rows }, []).map(row => row.top);
|
|
return { primary: bar.querySelector('.primary-button').textContent, more: more?.getAttribute('aria-label'),
|
|
overflow: bar.scrollWidth > rect.width + 1, rows: new Set(tops).size };
|
|
})()`)
|
|
assert.equal(toolbar.primary, '回顾')
|
|
assert.equal(toolbar.more, '更多回顾操作')
|
|
assert.equal(toolbar.overflow, false, 'Recap toolbar overflows')
|
|
assert(toolbar.rows <= 2, `Recap toolbar wraps into ${toolbar.rows} rows`)
|
|
await js('document.querySelector(".supervisor-workspace__toolbar [aria-haspopup=menu]").click()')
|
|
await wait('!!document.querySelector("[role=menu] [role=menuitem]")')
|
|
assert.equal(await js('document.querySelector("[role=menu] [role=menuitem]").textContent'), '重新整理…')
|
|
await js('document.querySelector("[role=menu] [role=menuitem]").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__confirm")')
|
|
const confirm = await js(`(() => { const el = document.querySelector('.supervisor-workspace__confirm'); const r = el.getBoundingClientRect();
|
|
return { width: r.width, overflow: el.scrollWidth > el.clientWidth + 1, text: el.textContent, buttons: [...el.querySelectorAll('button')].map(b => b.textContent) } })()`)
|
|
assert.equal(confirm.overflow, false, 'Re-analysis confirmation overflows')
|
|
assert.deepEqual(confirm.buttons, ['取消', '重新整理'])
|
|
assert(confirm.text.includes('可能产生较多模型用量'))
|
|
await settle()
|
|
await writeFile(join(artifacts, `recap-reanalyze-confirm-${win.getContentSize()[0]}.png`), (await win.webContents.capturePage()).toPNG())
|
|
await js('document.querySelector(".supervisor-workspace__confirm .secondary-button").click()')
|
|
await wait('!document.querySelector(".supervisor-workspace__confirm")')
|
|
await selectHistory('older-result')
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
|
await js('document.querySelector(".supervisor-workspace__toolbar .primary-button").click(); document.querySelector(".page-shell").scrollTop = 0')
|
|
await settle()
|
|
assert(await js('document.querySelector(".supervisor-workspace__recap").textContent.includes("上期结果")'))
|
|
assert(await js('!document.querySelector(".supervisor-workspace__run-status")'))
|
|
await writeFile(join(artifacts, 'recap-running-history-390.png'), (await win.webContents.capturePage()).toPNG())
|
|
await js('document.querySelector("#supervisor-tab-activity").click()')
|
|
await wait('document.querySelector("#supervisor-tab-activity").getAttribute("aria-selected") === "true"')
|
|
}
|
|
}
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(join(artifacts, 'recap-measurements.json'), JSON.stringify(reports, null, 2))
|
|
console.log(JSON.stringify({ artifacts, cases: reports.length, errors }))
|
|
win.destroy()
|
|
app.quit()
|
|
return
|
|
}
|
|
if (process.env.GOODBUDDY_SUPERVISOR_AUTOMATIC_OVERVIEW || process.env.GOODBUDDY_SUPERVISOR_DISCUSSION) {
|
|
for (const scenario of process.env.GOODBUDDY_SUPERVISOR_DISCUSSION ? [] : ['populated', 'empty', 'plan-only']) {
|
|
const empty = scenario !== 'populated'
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (scenario === 'populated' ? '?menu=1' : scenario === 'plan-only' ? '?plan-only=1' : ''))
|
|
await wait('!!document.querySelector("#supervisor-tab-plans")')
|
|
await js('document.fonts.ready')
|
|
await js('document.documentElement.dataset.theme = "light"')
|
|
for (const width of [1440, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
for (const tab of ['plans', 'activity']) {
|
|
await js(`document.querySelector('#supervisor-tab-${tab}').click(); document.querySelector('.page-shell').scrollTop = 0`)
|
|
if (tab === 'activity') await wait('document.querySelector(".supervisor-activity")?.getAttribute("aria-busy") === "false"')
|
|
await settle()
|
|
const report = await js(`(() => {
|
|
const panel = document.querySelector('#supervisor-panel-${tab}');
|
|
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth,
|
|
panelWidth: panel.clientWidth, scrollWidth: panel.scrollWidth,
|
|
overview: !!panel.querySelector('#heartbeat-panel-overview'),
|
|
metrics: !!panel.querySelector('.heartbeat-center__metrics'),
|
|
trend: !!panel.querySelector('#heartbeat-trend-title'),
|
|
audit: !!panel.querySelector('#heartbeat-runs-title'),
|
|
reports: !!panel.querySelector('#heartbeat-reports-title'),
|
|
suggestions: !!panel.querySelector('#supervision-suggestions-title'),
|
|
suggestionItems: panel.querySelectorAll('.supervision-suggestions__list > li').length,
|
|
emptyReports: panel.textContent.includes('暂无历史心跳报告'),
|
|
plans: !!panel.querySelector('#heartbeat-panel-plans'),
|
|
create: [...panel.querySelectorAll('button')].filter(b => b.textContent === '创建心跳计划').length,
|
|
emptyStates: panel.querySelectorAll('#heartbeat-panel-overview .empty-state').length,
|
|
activity: panel.querySelectorAll('.supervisor-activity__item').length };
|
|
})()`)
|
|
assert(report.pageWidth <= width && report.scrollWidth <= report.panelWidth, 'Automatic panel overflow')
|
|
for (const key of ['overview', 'plans']) assert.equal(report[key], tab === 'plans', key)
|
|
// Statistics, the trend and the run audit are gone; earlier reports stay readable.
|
|
for (const key of ['metrics', 'trend', 'audit']) assert.equal(report[key], false, key)
|
|
for (const key of ['reports', 'suggestions']) assert.equal(report[key], tab === 'plans' && !empty, key)
|
|
assert.equal(report.suggestionItems, tab === 'plans' && !empty ? 2 : 0)
|
|
assert.equal(report.emptyReports, false)
|
|
assert.equal(report.create, tab === 'plans' ? 1 : 0)
|
|
assert.equal(report.emptyStates, 0)
|
|
if (tab === 'activity') assert.equal(report.activity, 3)
|
|
reports.push({ scenario, empty, tab, ...report })
|
|
const prefix = `automatic-${scenario}-${tab}-light-${width}`
|
|
await writeFile(join(artifacts, `${prefix}.png`), (await win.webContents.capturePage()).toPNG())
|
|
if (tab === 'plans') {
|
|
for (const anchor of empty ? [] : ['supervision-suggestions-title', 'heartbeat-reports-title']) {
|
|
await js(`document.getElementById('${anchor}').scrollIntoView({block:'start'})`)
|
|
await settle()
|
|
await writeFile(join(artifacts, `${prefix}-${anchor}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
await js('document.querySelector(".heartbeat-settings__intro button").click()')
|
|
await wait('!!document.querySelector("[role=dialog]")')
|
|
assert(await js('document.querySelector("[role=dialog] > header button").getAttribute("aria-label") === "关闭心跳计划"'))
|
|
assert.equal(await js('document.querySelector("[role=dialog] > footer").textContent'), '取消保存并启用计划')
|
|
const intervention = await js(`(() => {
|
|
const dialog = document.querySelector('[role=dialog]');
|
|
const group = [...dialog.querySelectorAll('fieldset')].find(f => f.textContent.includes('介入方式'));
|
|
const buttons = group ? [...group.querySelectorAll('button')].filter(b => b.textContent === '生成建议' || b.textContent === '仅更新记忆') : [];
|
|
const body = dialog.querySelector('.custom-task-dialog__body, [class*=body]') || dialog;
|
|
return { present: !!group, options: buttons.map(b => b.textContent), pressed: buttons.find(b => b.getAttribute('aria-pressed') === 'true' || b.getAttribute('aria-checked') === 'true')?.textContent,
|
|
overflow: body.scrollWidth > body.clientWidth + 1 };
|
|
})()`)
|
|
assert.equal(intervention.present, true, 'Intervention field missing')
|
|
assert.deepEqual(intervention.options, ['生成建议', '仅更新记忆'])
|
|
assert.equal(intervention.overflow, false, 'Plan modal overflows horizontally')
|
|
if (width === 390) await writeFile(join(artifacts, `plan-modal-intervention-${scenario}-390.png`), (await win.webContents.capturePage()).toPNG())
|
|
await js('document.querySelector("[role=dialog] > header button").click()')
|
|
await wait('!document.querySelector("[role=dialog]")')
|
|
}
|
|
}
|
|
}
|
|
if (!empty) {
|
|
await js('document.querySelector("#supervisor-tab-plans").click()')
|
|
await js('[...document.querySelectorAll(".heartbeat-settings__actions button")].find(b => b.textContent === "执行记录").click()')
|
|
await wait('document.querySelector(".supervisor-activity select")?.value === "plan" && document.querySelectorAll(".supervisor-activity__item").length === 2')
|
|
assert(await js('!document.querySelector("#heartbeat-panel-overview, #heartbeat-runs-title")'))
|
|
await js('[...document.querySelectorAll(".supervisor-activity button")].find(b => b.textContent === "清除筛选").click()')
|
|
await wait('document.querySelectorAll(".supervisor-activity__item").length === 3')
|
|
}
|
|
}
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(join(artifacts, 'automatic-overview-measurements.json'), JSON.stringify(reports, null, 2))
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?preview=1')
|
|
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
|
await js('document.querySelector("#supervisor-tab-graph").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__map")')
|
|
await js(`document.querySelector('.supervisor-workspace__map [aria-label="观察工作过程"]').dispatchEvent(new MouseEvent('click', {bubbles:true}))`)
|
|
await settle()
|
|
await js('document.querySelector(".supervisor-workspace__detail > .link-button").click()')
|
|
await wait('!!document.querySelector(".supervisor-discussion > button")')
|
|
await js('document.querySelector(".supervisor-discussion > button").click()')
|
|
await wait('!!document.querySelector(".supervisor-discussion textarea")')
|
|
await js('document.fonts.ready')
|
|
const previews = []
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const width of [1440, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
await js(`document.querySelector('.supervisor-discussion').scrollIntoView({block:'center'})`)
|
|
await settle()
|
|
const report = await js(`(() => {
|
|
const preview = document.querySelector('.supervisor-discussion');
|
|
const box = e => { const r = e.getBoundingClientRect(); return {left:r.left, right:r.right, top:r.top, bottom:r.bottom}; };
|
|
return { width:innerWidth, pageWidth:document.documentElement.scrollWidth,
|
|
clientWidth:preview.clientWidth, scrollWidth:preview.scrollWidth,
|
|
question:preview.querySelector('textarea').value, text:preview.textContent,
|
|
controls:[...preview.querySelectorAll('textarea, button')].map(box) };
|
|
})()`)
|
|
assert(report.pageWidth <= width && report.scrollWidth <= report.clientWidth, 'Discussion preview overflow')
|
|
assert(report.question.trim() && report.text.includes('模拟会议记录'), 'Discussion question and destination visible')
|
|
assert(report.controls.every(box => box.left >= 0 && box.right <= width && box.top >= 0 && box.bottom <= 1100), 'Preview controls fit viewport')
|
|
assert(await js('!document.querySelector(".supervisor-discussion [role=alert]")'), 'Preview loads without error')
|
|
assert(await js('!document.querySelector(".supervisor-discussion details")'), 'No redundant context disclosure')
|
|
previews.push({ theme, ...report, editor: await checkDiscussion() })
|
|
await writeFile(join(artifacts, `discussion-preview-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
await js('document.querySelector(".supervisor-discussion .secondary-button").click()')
|
|
await wait('!document.querySelector(".supervisor-discussion textarea")')
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(join(artifacts, 'discussion-preview-measurements.json'), JSON.stringify(previews, null, 2))
|
|
console.log(JSON.stringify({ artifacts, cases: reports.length, errors }))
|
|
win.destroy()
|
|
app.quit()
|
|
return
|
|
}
|
|
if (process.env.GOODBUDDY_SUPERVISOR_SIDEBAR) {
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL)
|
|
await wait('!!document.querySelector(".supervision-card")')
|
|
await js('document.fonts.ready')
|
|
win.show()
|
|
win.focus()
|
|
await wait('[...document.querySelectorAll(".supervision-card button")].some(b => b.textContent === "继续讨论" && !b.disabled)')
|
|
await js('[...document.querySelectorAll(".supervision-card button")].find(b => b.textContent === "继续讨论").click()')
|
|
await wait('!!document.querySelector(".supervision-discussion-editor textarea")')
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const width of [480, 300, 200]) {
|
|
win.setContentSize(width, 640)
|
|
await js('document.querySelector(".supervision-card__header button").focus()')
|
|
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Tab' })
|
|
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Tab' })
|
|
await settle()
|
|
const report = await js(`(() => {
|
|
const card = document.querySelector('.supervision-card');
|
|
const header = card.querySelector('.supervision-card__header');
|
|
const buttons = [...header.querySelectorAll('button')];
|
|
return {
|
|
width: innerWidth, scrollWidth: document.documentElement.scrollWidth,
|
|
cardWidth: card.clientWidth, cardScrollWidth: card.scrollWidth,
|
|
headerHeight: header.getBoundingClientRect().height,
|
|
buttons: buttons.map(b => ({ width: b.getBoundingClientRect().width,
|
|
height: b.getBoundingClientRect().height, background: getComputedStyle(b).backgroundColor })),
|
|
focus: getComputedStyle(document.activeElement).outlineStyle,
|
|
text: card.textContent
|
|
};
|
|
})()`)
|
|
assert(report.scrollWidth <= report.width, `Page overflow: ${JSON.stringify(report)}`)
|
|
assert(report.cardScrollWidth <= report.cardWidth, 'Card overflow')
|
|
assert.equal(report.headerHeight, 34)
|
|
assert(report.buttons.every(b => b.width === 34 && b.height === 34), 'Compact shared buttons')
|
|
assert.equal(report.focus, 'solid')
|
|
assert(!report.text.includes('simulated-conversation-uuid'), 'Raw UUID visible')
|
|
reports.push({ theme, ...report, editor: await checkDiscussion() })
|
|
await js('document.querySelector(".supervision-discussion-editor footer").scrollIntoView({block:"end"})')
|
|
await settle()
|
|
assert(await js(`([...document.querySelectorAll('.supervision-discussion-editor footer button')].every(e => {
|
|
const r = e.getBoundingClientRect();
|
|
return r.top >= 0 && r.bottom <= innerHeight && e.contains(document.elementFromPoint(r.x+r.width/2, r.y+r.height/2));
|
|
}))`), 'Sidebar footer reachable and hittable after scrolling')
|
|
await writeFile(join(artifacts, `sidebar-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(join(artifacts, 'sidebar-measurements.json'), JSON.stringify(reports, null, 2))
|
|
console.log(JSON.stringify({ artifacts, cases: reports.length, errors }))
|
|
win.destroy()
|
|
app.quit()
|
|
return
|
|
}
|
|
if (process.env.GOODBUDDY_SUPERVISOR_PLAN_MODAL) {
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?menu=1')
|
|
await wait('!!document.querySelector("#supervisor-tab-plans")')
|
|
await js('document.fonts.ready')
|
|
await js('document.querySelector("#supervisor-tab-plans").click()')
|
|
assert.equal(await js('document.querySelectorAll("[role=tab]").length'), 5)
|
|
win.show()
|
|
win.focus()
|
|
for (const mode of ['create', 'edit']) {
|
|
const opener = mode === 'create' ? '.heartbeat-settings__intro button' : '.heartbeat-settings__actions button[aria-label^="编辑"]'
|
|
await js(`document.querySelector('${opener}').focus(); document.querySelector('${opener}').click()`)
|
|
await wait('!!document.querySelector("[role=dialog]")')
|
|
assert(await js('document.activeElement === document.querySelector("[role=dialog] input")'), 'Name initial focus')
|
|
await js(`(() => {
|
|
const select = document.querySelector('[aria-label="监督频率"]');
|
|
select.value = 'weekly'; select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
})()`)
|
|
await wait('!!document.querySelector("[aria-label=监督星期]")')
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const [width, height] of [[1440, 1100], [390, 1100], [390, 480]]) {
|
|
win.setContentSize(width, height)
|
|
await settle()
|
|
const measure = () => js(`(() => {
|
|
const dialog = document.querySelector('[role=dialog]');
|
|
const header = dialog.querySelector(':scope > header'), footer = dialog.querySelector(':scope > footer');
|
|
const body = dialog.querySelector(':scope > .custom-task-dialog__content');
|
|
const box = e => { const r = e.getBoundingClientRect(); return { left:r.left, right:r.right, top:r.top, bottom:r.bottom, width:r.width, height:r.height }; };
|
|
const close = header.querySelector('button'), buttons = [...footer.querySelectorAll('button')];
|
|
const hit = e => { const r = e.getBoundingClientRect(); return e.contains(document.elementFromPoint(r.x+r.width/2, r.y+r.height/2)); };
|
|
return { width:innerWidth, height:innerHeight, pageWidth:document.documentElement.scrollWidth,
|
|
dialog:box(dialog), header:box(header), title:box(header.querySelector('h2')), close:box(close),
|
|
footer:box(footer), buttons:buttons.map(box), hit:[close,...buttons].every(hit),
|
|
headerButtons:header.querySelectorAll('button').length, closeLabel:close.getAttribute('aria-label'),
|
|
tooltip:close.title, bodyWidth:body.clientWidth, bodyScrollWidth:body.scrollWidth,
|
|
bodyHeight:body.clientHeight, bodyScrollHeight:body.scrollHeight, bodyTop:body.scrollTop,
|
|
footerAlign:getComputedStyle(footer).justifyContent, text:buttons.map(e=>e.textContent) };
|
|
})()`)
|
|
const before = await measure()
|
|
assert(before.pageWidth <= width && before.bodyScrollWidth <= before.bodyWidth, 'No horizontal overflow')
|
|
assert(before.dialog.top >= 0 && before.dialog.bottom <= height, 'Dialog fits viewport')
|
|
assert(before.headerButtons === 1 && before.closeLabel === '关闭心跳计划' && before.tooltip === before.closeLabel, 'Accessible header X only')
|
|
assert(before.title.right <= before.close.left && before.close.right > (before.dialog.left + before.dialog.right) / 2, 'Title left, close right')
|
|
assert.equal(before.footerAlign, 'flex-end')
|
|
assert.equal(before.text[0], '取消')
|
|
assert(before.buttons[0].right <= before.buttons[1].left && before.buttons[0].top === before.buttons[1].top, 'Cancel then save on same row')
|
|
assert(before.buttons[1].right > (before.dialog.left + before.dialog.right) / 2 && before.hit, 'Footer right and controls hittable')
|
|
await js('document.querySelector("[role=dialog] > .custom-task-dialog__content").scrollTop = 10000')
|
|
await settle()
|
|
const after = await measure()
|
|
assert.deepEqual(after.header, before.header, 'Header stays visible when body scrolls')
|
|
assert.deepEqual(after.footer, before.footer, 'Footer stays visible when body scrolls')
|
|
assert(after.hit, 'Scrolled controls hittable')
|
|
if (height === 480) assert(after.bodyTop > 0 && after.bodyScrollHeight > after.bodyHeight, 'Short body scrolls')
|
|
reports.push({ mode, theme, before, after })
|
|
await writeFile(join(artifacts, `plan-${mode}-${theme}-${width}x${height}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
win.focus()
|
|
win.webContents.focus()
|
|
await js('document.querySelector("[role=dialog] > header button").focus()')
|
|
const key = async (keyCode, modifiers = []) => {
|
|
win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
|
|
if (keyCode === 'Enter') win.webContents.sendInputEvent({ type: 'char', keyCode: '\r' })
|
|
win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
|
|
await settle()
|
|
}
|
|
await key('Tab', ['shift'])
|
|
assert(await js('document.activeElement.matches("footer .primary-button")'), 'Shift Tab wraps to save')
|
|
await key('Tab', ['shift'])
|
|
assert(await js('document.activeElement.matches("footer .secondary-button")'), 'Footer cancel keyboard reachable')
|
|
const focus = await js('({ focused: document.hasFocus(), visible: document.activeElement.matches(":focus-visible"), outline: getComputedStyle(document.activeElement).outlineStyle })')
|
|
assert(focus.visible && focus.outline === 'solid', `Visible keyboard focus: ${JSON.stringify(focus)}`)
|
|
await key('Enter')
|
|
await wait('document.querySelector("[role=dialog]").textContent.includes("放弃未保存")')
|
|
assert(await js('document.activeElement.textContent === "继续编辑"'), 'Confirmation initial focus')
|
|
await key('Escape')
|
|
assert(await js('document.activeElement === document.querySelector("[role=dialog] input")'), 'Escape returns to editing')
|
|
await js('document.querySelector("[role=dialog] > header button").click()')
|
|
await wait('!!document.querySelector("[role=dialog] .danger-solid")')
|
|
await js('document.querySelector("[role=dialog] .danger-solid").click()')
|
|
await wait('!document.querySelector("[role=dialog]")')
|
|
assert(await js(`document.activeElement === document.querySelector('${opener}')`), 'Opener focus restored')
|
|
}
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(join(artifacts, 'plan-modal-measurements.json'), JSON.stringify(reports, null, 2))
|
|
console.log(JSON.stringify({ artifacts, cases: reports.length, errors }))
|
|
win.destroy()
|
|
app.quit()
|
|
return
|
|
}
|
|
await validateSupervisorSelection(win, js, wait, process.env.GOODBUDDY_SUPERVISOR_URL)
|
|
await open(false)
|
|
await js(
|
|
'document.querySelector(".supervisor-workspace__canvas-tools .link-button").click()'
|
|
)
|
|
await wait(
|
|
'!document.querySelector(".supervisor-workspace__map.has-selection")'
|
|
)
|
|
assert(await js(`(() => {
|
|
const ring = document.querySelector('.supervisor-workspace__timeline-ring');
|
|
const start = ring.getPointAtLength(0), next = ring.getPointAtLength(20), end = ring.getPointAtLength(ring.getTotalLength());
|
|
return next.x > start.x && next.y < start.y && end.x < start.x && end.y > 490;
|
|
})()`), 'Time must run counter-clockwise and leave a bottom gap')
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const width of [1440, 1024, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
await settle()
|
|
const report = await js(`(() => {
|
|
const q = s => document.querySelector('.supervisor-workspace' + s);
|
|
const box = e => { const r = e.getBoundingClientRect(); return { width: r.width, height: r.height, top: r.top, bottom: r.bottom, clientWidth: e.clientWidth, scrollWidth: e.scrollWidth }; };
|
|
const svg = q('__map'), scroller = q('__map-scroll');
|
|
const texts = [...svg.querySelectorAll('text')].map(e => {
|
|
const b = e.getBBox(), c = getComputedStyle(e), r = e.getBoundingClientRect();
|
|
return { text: e.textContent, width: b.width, height: b.height, fontSize: c.fontSize,
|
|
screenFontSize: parseFloat(c.fontSize) * e.getScreenCTM().a, fill: c.fill,
|
|
inSvg: b.x >= 0 && b.y >= 0 && b.x + b.width <= 760 && b.y + b.height <= 620,
|
|
painted: c.display !== 'none' && c.visibility === 'visible' && c.fill !== 'none' && Number(c.opacity) > 0 && r.width > 0,
|
|
x: b.x, y: b.y };
|
|
});
|
|
const tokens = Object.fromEntries(${process.env.GOODBUDDY_SUPERVISOR_TOKENS}.map(t => [t, getComputedStyle(q('')).getPropertyValue(t).trim()]));
|
|
return { viewport: innerWidth, page: box(document.documentElement), shell: box(document.querySelector('.page-shell')),
|
|
container: box(q('')), grid: getComputedStyle(q('__graph-layout')).gridTemplateColumns,
|
|
list: box(q('__graph-list')), canvas: box(q('__graph-canvas')), detail: box(q('__detail')), svg: box(svg), scroller: box(scroller), texts, tokens };
|
|
})()`)
|
|
reports.push({ theme, ...report })
|
|
await writeFile(
|
|
join(artifacts, `${theme}-${width}.png`),
|
|
(await win.webContents.capturePage()).toPNG()
|
|
)
|
|
assert(report.page.scrollWidth <= width, JSON.stringify(report))
|
|
if (width >= 1024) {
|
|
assert(Math.abs(report.list.top - report.canvas.top) < 1, 'Sidebar top alignment')
|
|
assert(Math.abs(report.list.bottom - report.canvas.bottom) < 1,
|
|
`Sidebar must fill canvas height: ${JSON.stringify({ list: report.list, canvas: report.canvas })}`)
|
|
if (width === 1440)
|
|
assert(Math.abs(report.list.bottom - report.detail.bottom) < 1, 'Sidebar/detail height alignment')
|
|
}
|
|
assert(
|
|
report.shell.scrollWidth <= report.shell.clientWidth,
|
|
'Shell overflow'
|
|
)
|
|
assert(
|
|
report.container.scrollWidth <= report.container.clientWidth,
|
|
'Container overflow'
|
|
)
|
|
assert(
|
|
report.texts.every(
|
|
(text) => text.inSvg && text.painted && (width < 1024 || text.screenFontSize >= 11)
|
|
),
|
|
`Clipped or invisible SVG text at ${width}: ${JSON.stringify(report.texts.filter(text => !(text.inSvg && text.painted && text.screenFontSize >= 11)).slice(0, 3))} svg=${JSON.stringify(report.svg)}`
|
|
)
|
|
for (const [index, a] of report.texts.entries()) {
|
|
for (const b of report.texts.slice(index + 1)) {
|
|
assert(
|
|
!(
|
|
a.x < b.x + b.width &&
|
|
a.x + a.width > b.x &&
|
|
a.y < b.y + b.height &&
|
|
a.y + a.height > b.y
|
|
),
|
|
`Overlapping labels: ${a.text} / ${b.text}`
|
|
)
|
|
}
|
|
}
|
|
assert(
|
|
Object.values(report.tokens).every(Boolean),
|
|
'Unresolved CSS variable'
|
|
)
|
|
assert(
|
|
report.texts.some((text) => text.text === '工作过程'),
|
|
'Missing real entity label'
|
|
)
|
|
if (width === 1440)
|
|
assert(
|
|
report.canvas.width > report.list.width + report.detail.width,
|
|
'Canvas must take priority'
|
|
)
|
|
if (width === 1440) {
|
|
await js('document.querySelector(".supervisor-workspace__graph-layout").scrollIntoView({block:"end"})')
|
|
await settle()
|
|
await writeFile(join(artifacts, `${theme}-1440-aligned-bottom.png`), (await win.webContents.capturePage()).toPNG())
|
|
await js('document.querySelector(".page-shell").scrollTop = 0')
|
|
}
|
|
if (width >= 1024)
|
|
assert.equal(
|
|
report.scroller.scrollWidth,
|
|
report.scroller.clientWidth,
|
|
'Desktop canvas must fit'
|
|
)
|
|
if (width === 390) {
|
|
assert(
|
|
report.scroller.scrollWidth === report.scroller.clientWidth,
|
|
'Mobile canvas fits without internal scrolling; full labels remain in the list'
|
|
)
|
|
await js(
|
|
'document.querySelector(".supervisor-workspace__map-scroll").scrollLeft = 600'
|
|
)
|
|
assert(
|
|
await js(
|
|
'document.querySelector(".supervisor-workspace__map-scroll").scrollLeft === 0'
|
|
)
|
|
)
|
|
await writeFile(
|
|
join(artifacts, `${theme}-${width}-scrolled.png`),
|
|
(await win.webContents.capturePage()).toPNG()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
// The viewport stays wide while the actual workspace is constrained.
|
|
win.setContentSize(1440, 1100)
|
|
await js(
|
|
`document.documentElement.dataset.theme = 'light'; document.querySelector('.supervisor-workspace__map [aria-label="观察工作过程"]').dispatchEvent(new MouseEvent('click', {bubbles:true}))`
|
|
)
|
|
await settle()
|
|
await js(
|
|
'document.querySelector(".supervisor-workspace__detail > .link-button").click()'
|
|
)
|
|
await wait('!!document.querySelector(".supervisor-workspace__source")')
|
|
assert(
|
|
await js(
|
|
'document.querySelector(".supervisor-workspace__source").textContent.includes("不是真实用户数据")'
|
|
)
|
|
)
|
|
await settle()
|
|
await writeFile(
|
|
join(artifacts, 'selected-source-1440.png'),
|
|
(await win.webContents.capturePage()).toPNG()
|
|
)
|
|
win.setContentSize(390, 1100)
|
|
await js(
|
|
'document.querySelector(".supervisor-workspace__detail").scrollIntoView({block:"start"})'
|
|
)
|
|
await settle()
|
|
await writeFile(
|
|
join(artifacts, 'selected-source-390.png'),
|
|
(await win.webContents.capturePage()).toPNG()
|
|
)
|
|
assert(
|
|
await js('document.documentElement.scrollWidth <= innerWidth'),
|
|
'Mobile source overflow'
|
|
)
|
|
win.setContentSize(1440, 1100)
|
|
for (const width of [800, 860, 861, 980, 1000, 1010, 1110, 1190, 1191]) {
|
|
await js(
|
|
`document.querySelector('.supervisor-workspace').style.width = '${width}px'`
|
|
)
|
|
await settle()
|
|
const result = await js(
|
|
`(() => { const e = document.querySelector('.supervisor-workspace'); return { width: e.clientWidth, scrollWidth: e.scrollWidth, columns: getComputedStyle(e.querySelector('.supervisor-workspace__graph-layout')).gridTemplateColumns }; })()`
|
|
)
|
|
assert.equal(result.width, result.scrollWidth, 'Breakpoint overflow')
|
|
reports.push({ constrainedContainer: result })
|
|
}
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?short=1')
|
|
await wait('!!document.querySelector("#supervisor-tab-graph")')
|
|
await js('document.querySelector("#supervisor-tab-graph").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__map")')
|
|
for (const width of [1440, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
await settle()
|
|
const short = await js(`(() => {
|
|
const list = document.querySelector('.supervisor-workspace__graph-list');
|
|
const r = list.getBoundingClientRect();
|
|
return { width: innerWidth, height: r.height, clientHeight: list.clientHeight, scrollHeight: list.scrollHeight,
|
|
canvasHeight: document.querySelector('.supervisor-workspace__graph-canvas').getBoundingClientRect().height,
|
|
buttonHeights: [...list.querySelectorAll('button')].map(e => e.getBoundingClientRect().height) };
|
|
})()`)
|
|
assert(short.buttonHeights.every(height => height < 80), 'Short rows must not stretch to fill space')
|
|
assert.equal(short.scrollHeight, short.clientHeight, 'Short list must not acquire artificial overflow')
|
|
if (width === 1440) assert(Math.abs(short.height - short.canvasHeight) < 1, 'Short sidebar must still fill its row')
|
|
else assert(short.height <= 320, 'Short mobile list must use its natural height')
|
|
reports.push({ shortList: short })
|
|
await js('document.querySelector(".supervisor-workspace__graph-list").scrollIntoView({block:"end"})')
|
|
await settle()
|
|
await writeFile(join(artifacts, `short-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
win.setContentSize(1440, 1100)
|
|
await open(true)
|
|
for (const width of [1440, 1024, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
await settle()
|
|
await js(`(() => {const list=document.querySelector('.supervisor-workspace__list-panel'); list.scrollIntoView({block:'end'}); list.scrollTop=list.scrollHeight;list.dispatchEvent(new Event('scroll'))})()`)
|
|
await wait('!!document.querySelector("[data-list-window-row=event-79] button")')
|
|
await settle()
|
|
const scrolling = await js(`(() => {
|
|
const sidebar = document.querySelector('.supervisor-workspace__graph-list');
|
|
const list = sidebar.querySelector('[role=tabpanel]');
|
|
const canvas = document.querySelector('.supervisor-workspace__graph-canvas');
|
|
const shell = document.querySelector('.page-shell');
|
|
list.scrollIntoView({block:'end'});
|
|
const pageHeight = shell.scrollHeight, pageTop = shell.scrollTop;
|
|
list.scrollTop = list.scrollHeight;
|
|
const last = list.lastElementChild.getBoundingClientRect();
|
|
const button = list.querySelector('[data-list-window-row="event-79"] button');
|
|
const b = button.getBoundingClientRect(), r = list.getBoundingClientRect();
|
|
return { width: innerWidth, height: sidebar.getBoundingClientRect().height, canvasHeight: canvas.getBoundingClientRect().height,
|
|
clientHeight: list.clientHeight, scrollHeight: list.scrollHeight, scrollTop: list.scrollTop,
|
|
bottomGap: r.bottom - last.bottom,
|
|
paddingBottom: parseFloat(getComputedStyle(list).paddingBottom),
|
|
bottomVisible: last.bottom <= r.bottom && last.top >= r.top,
|
|
lastButtonHit: button.contains(document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2)),
|
|
pageStable: pageHeight === shell.scrollHeight && pageTop === shell.scrollTop,
|
|
pageHeight };
|
|
})()`)
|
|
assert(scrolling.scrollHeight > scrolling.clientHeight, 'Dense list must scroll locally')
|
|
assert(Math.abs(scrolling.scrollTop + scrolling.clientHeight - scrolling.scrollHeight) <= 1, 'List bottom unreachable')
|
|
assert(scrolling.bottomVisible && scrolling.lastButtonHit, 'Last relation and trailing copy must be visible')
|
|
assert(Math.abs(scrolling.bottomGap - scrolling.paddingBottom) <= 1,
|
|
`List must use the full scroll area, leaving only its padding: ${JSON.stringify(scrolling)}`)
|
|
assert(scrolling.pageStable, 'List scrolling must not move or grow the page')
|
|
if (width >= 1024)
|
|
assert(Math.abs(scrolling.height - scrolling.canvasHeight) < 1, 'Dense list must follow canvas height')
|
|
else
|
|
assert(scrolling.height <= 320, 'Single-column list must remain bounded')
|
|
reports.push({ denseScrolling: scrolling })
|
|
await settle()
|
|
await writeFile(join(artifacts, `dense-bottom-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
// The graph row fills the window down to a steady bottom margin; each column scrolls on its own.
|
|
for (const [width, height] of [[1440, 1200], [1440, 900], [1440, 700], [1024, 1100]]) {
|
|
win.setContentSize(width, height)
|
|
await settle()
|
|
const fill = await js(`(() => {
|
|
const shell = document.querySelector('.page-shell');
|
|
shell.scrollTop = 0;
|
|
const layout = document.querySelector('.supervisor-workspace__graph-layout');
|
|
const shellBox = shell.getBoundingClientRect(), box = layout.getBoundingClientRect();
|
|
const columns = [...layout.children].filter(column => column.getClientRects().length).map(column => {
|
|
const scroller = column.matches('.supervisor-workspace__graph-list') ? column.querySelector('[role=tabpanel]') : column;
|
|
const r = column.getBoundingClientRect();
|
|
return { name: column.className.split(' ')[0], canvas: column.matches('.supervisor-workspace__graph-canvas'), top: r.top, bottom: r.bottom,
|
|
scrolls: getComputedStyle(scroller).overflowY, clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight };
|
|
});
|
|
const canvas = layout.querySelector('.supervisor-workspace__graph-canvas'), map = layout.querySelector('.supervisor-workspace__map').getBoundingClientRect();
|
|
const playback = layout.querySelector('.supervisor-workspace__playback')?.getBoundingClientRect();
|
|
const graphArea = { map: { top: map.top, bottom: map.bottom, height: map.height }, playbackBottom: playback?.bottom, canvasBottom: canvas.getBoundingClientRect().bottom };
|
|
// Bottom of the first grid row (the side-by-side columns), measured from the window bottom.
|
|
const rowBottom = Math.max(...columns.filter(column => Math.abs(column.top - columns[0].top) < 1).map(column => column.bottom));
|
|
return { bottomGap: shellBox.bottom - rowBottom, wrapped: columns.length - columns.filter(column => Math.abs(column.top - columns[0].top) < 1).length,
|
|
height: box.height, pageScroll: shell.scrollHeight - shell.clientHeight, columns, graphArea };
|
|
})()`)
|
|
reports.push({ fill: { width, height, ...fill } })
|
|
await writeFile(join(artifacts, `fill-${width}x${height}.json`), JSON.stringify({ width, height, bottomGap: Math.round(fill.bottomGap), wrapped: fill.wrapped, pageScroll: fill.pageScroll, rowHeight: Math.round(fill.columns[0].bottom - fill.columns[0].top), mapHeight: Math.round(fill.graphArea.map.height) }))
|
|
// At the 660px minimum (labels stay ≥ 11px) a short window scrolls the page; otherwise the row ends at the bottom margin.
|
|
const atMinimum = Math.abs(fill.columns[0].bottom - fill.columns[0].top - 660) < 1
|
|
if (!atMinimum) {
|
|
assert(fill.bottomGap >= 20 && fill.bottomGap <= 40, `Graph must stop above the window bottom: ${JSON.stringify(fill)}`)
|
|
// Three columns fit the window with no page scroll; at two columns only the wrapped detail extends the page.
|
|
if (!fill.wrapped) assert(fill.pageScroll <= 1, `Page must not scroll at three columns: ${JSON.stringify(fill)}`)
|
|
}
|
|
const sideBySide = fill.columns.filter(column => Math.abs(column.top - fill.columns[0].top) < 1)
|
|
for (const column of sideBySide) {
|
|
assert(Math.abs(column.bottom - sideBySide[0].bottom) < 1, `Columns must share the row height: ${JSON.stringify(fill)}`)
|
|
// The graph column has no scroll bar: the graph takes the height left after the heading and event bar.
|
|
if (column.canvas) assert(column.scrollHeight <= column.clientHeight + 1 && column.scrolls === 'hidden', `Graph column must not scroll: ${JSON.stringify(column)}`)
|
|
else assert(['auto', 'scroll'].includes(column.scrolls), `Column must scroll on its own: ${JSON.stringify(column)}`)
|
|
}
|
|
if (width === 1440) {
|
|
const heading = await js(`(() => {
|
|
const context = document.querySelector('.supervisor-workspace__review-context');
|
|
return { meta: context.querySelector('.sr-only').textContent,
|
|
above: context.getBoundingClientRect().bottom <= document.querySelector('.supervisor-workspace__graph-layout').getBoundingClientRect().top,
|
|
heading: document.querySelector('.supervisor-workspace__canvas-heading').textContent };
|
|
})()`)
|
|
assert(heading.above && heading.meta.includes('全局') && !heading.heading.includes('事件与知识'), `Review context shared above graph: ${JSON.stringify(heading)}`)
|
|
reports.push({ heading })
|
|
}
|
|
assert(Math.abs((fill.graphArea.playbackBottom ?? fill.graphArea.map.bottom) - fill.graphArea.canvasBottom) <= 1,
|
|
`Graph area must reach the column bottom: ${JSON.stringify(fill.graphArea)}`)
|
|
await writeFile(join(artifacts, `fill-${width}x${height}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
win.setContentSize(1440, 1100)
|
|
await js(
|
|
'document.querySelectorAll(".supervisor-workspace__graph-list [role=tab]")[2].click()'
|
|
)
|
|
await settle()
|
|
await js('document.querySelector(".supervisor-workspace__list-panel button:last-of-type").click()')
|
|
await settle()
|
|
assert(
|
|
await js('document.querySelectorAll("[data-relation]").length === 1'),
|
|
'Selected relation endpoints must remain visible'
|
|
)
|
|
await js('document.querySelectorAll(".supervisor-workspace__graph-list [role=tab]")[0].click()')
|
|
await js(`document.querySelector('.supervisor-workspace__list-panel').dispatchEvent(new KeyboardEvent('keydown',{key:'End',bubbles:true}))`)
|
|
await settle()
|
|
const target = await js(
|
|
`(() => { const e = document.querySelector('[data-list-window-row="event-79"] button'); e.scrollIntoView({block:'center'}); const 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)) }; })()`
|
|
)
|
|
assert(target.hit, 'Last event is not reachable')
|
|
win.webContents.sendInputEvent({
|
|
type: 'mouseDown',
|
|
x: target.x,
|
|
y: target.y,
|
|
button: 'left',
|
|
clickCount: 1
|
|
})
|
|
win.webContents.sendInputEvent({
|
|
type: 'mouseUp',
|
|
x: target.x,
|
|
y: target.y,
|
|
button: 'left',
|
|
clickCount: 1
|
|
})
|
|
await settle()
|
|
assert(
|
|
await js(
|
|
`!!document.querySelector('.supervisor-workspace__map [aria-label="模拟事件 80"][aria-pressed=true]')`
|
|
)
|
|
)
|
|
assert(await js('document.querySelectorAll(".supervisor-workspace__list-panel button").length < 80'), 'Dense list is windowed without losing the last event')
|
|
const positions = await js(
|
|
'[...document.querySelectorAll(".supervisor-workspace__node:not(.supervisor-workspace__entity) circle")].map(e => [e.getAttribute("cx"), e.getAttribute("cy")].join())'
|
|
)
|
|
assert.equal(new Set(positions).size, 8)
|
|
assert(await js(`(() => {
|
|
return [...document.querySelectorAll('.supervisor-workspace__event-index')].every(e => {
|
|
const range = document.createRange(); range.selectNodeContents(e);
|
|
const tops = [...range.getClientRects()].map(r => r.top);
|
|
return Math.max(...tops) - Math.min(...tops) < 1;
|
|
});
|
|
})()`), 'Dense event numbers must not wrap')
|
|
await js(
|
|
'document.querySelector(".supervisor-workspace__graph-canvas").scrollIntoView({block:"start"})'
|
|
)
|
|
await settle()
|
|
await writeFile(
|
|
join(artifacts, 'dense-selected.png'),
|
|
(await win.webContents.capturePage()).toPNG()
|
|
)
|
|
await js('document.querySelector("#supervisor-tab-settings").click()')
|
|
await settle()
|
|
assert.equal(
|
|
await js('document.querySelectorAll("h1").length'),
|
|
1,
|
|
'Duplicate settings h1'
|
|
)
|
|
await writeFile(
|
|
join(artifacts, 'settings.png'),
|
|
(await win.webContents.capturePage()).toPNG()
|
|
)
|
|
// Settings are grouped into one row of section tabs, with one visible form at a time.
|
|
assert.deepEqual(await js('[...document.querySelectorAll("#supervisor-panel-settings [role=tablist] [role=tab]")].map(t => t.textContent)'),
|
|
['模型', '回顾整理', '故事与经验', '建议'])
|
|
assert.equal(await js('document.querySelectorAll("[role=tablist]").length'), 2, 'Page tabs plus one settings section row')
|
|
for (const section of ['model', 'review', 'stories', 'suggestions']) {
|
|
await js(`document.querySelector('#supervisor-settings-tab-${section}').click()`)
|
|
for (const width of [1440, 1024, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
await js('document.querySelector(".page-shell").scrollTop = 0')
|
|
await settle()
|
|
const sizes = await js(`(() => {
|
|
const shell = document.querySelector('.page-shell');
|
|
const forms = [...document.querySelectorAll('#supervisor-panel-settings form')].filter(f => f.getClientRects().length);
|
|
return { viewport: innerWidth, page: document.documentElement.scrollWidth,
|
|
clientWidth: shell.clientWidth, scrollWidth: shell.scrollWidth, headings: document.querySelectorAll('h1').length,
|
|
visibleForms: forms.length, title: forms[0]?.querySelector('h2')?.textContent };
|
|
})()`)
|
|
assert(
|
|
sizes.page <= width && sizes.scrollWidth <= sizes.clientWidth,
|
|
`Settings overflow: ${section}/${width}`
|
|
)
|
|
assert.equal(sizes.headings, 1)
|
|
assert.equal(sizes.visibleForms, 1, `One settings form per section: ${section}`)
|
|
reports.push({ settings: section, ...sizes })
|
|
await writeFile(
|
|
join(artifacts, `settings-${section}-${width}.png`),
|
|
(await win.webContents.capturePage()).toPNG()
|
|
)
|
|
}
|
|
}
|
|
for (const query of [
|
|
'story=1',
|
|
'story=2',
|
|
'long=1',
|
|
'state=loading',
|
|
'state=error',
|
|
'state=unavailable',
|
|
'state=empty'
|
|
]) {
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?' + query)
|
|
await wait('!!document.querySelector("#supervisor-tab-graph")')
|
|
await js('document.querySelector("#supervisor-tab-graph").click()')
|
|
await wait(
|
|
query === 'state=loading'
|
|
? '!!document.querySelector(".empty-state")'
|
|
: '!document.querySelector(".supervisor-workspace[aria-busy=true]")'
|
|
)
|
|
for (const width of [1440, 1024, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
await settle()
|
|
assert(
|
|
await js('document.documentElement.scrollWidth <= innerWidth'),
|
|
`Overflow: ${query}/${width}`
|
|
)
|
|
if (!query.startsWith('state=')) {
|
|
const labels = await js(
|
|
'[...document.querySelectorAll(".supervisor-workspace__map text")].map(e => { const b = e.getBBox(); return { text:e.textContent, x:b.x, y:b.y, width:b.width, height:b.height } })'
|
|
)
|
|
for (const [index, a] of labels.entries()) {
|
|
for (const b of labels.slice(index + 1)) {
|
|
assert(
|
|
!(
|
|
a.x < b.x + b.width &&
|
|
a.x + a.width > b.x &&
|
|
a.y < b.y + b.height &&
|
|
a.y + a.height > b.y
|
|
),
|
|
`Overlapping ${query}: ${a.text} / ${b.text}`
|
|
)
|
|
}
|
|
}
|
|
reports.push({ scenario: query, viewport: width, labels })
|
|
}
|
|
await writeFile(
|
|
join(artifacts, `${query.replace('=', '-')}-${width}.png`),
|
|
(await win.webContents.capturePage()).toPNG()
|
|
)
|
|
}
|
|
}
|
|
await js('document.querySelector("#supervisor-tab-overview").click()')
|
|
await settle()
|
|
await js('document.querySelector(".supervisor-workspace__review-context .primary-button").click()')
|
|
await wait('!!document.querySelector(".supervisor-workspace__toolbar .primary-button")')
|
|
await js(
|
|
'document.querySelector(".supervisor-workspace__toolbar .primary-button").click()'
|
|
)
|
|
await wait(
|
|
'document.documentElement.dataset.reviewNoticeTone === "error"'
|
|
)
|
|
assert(
|
|
await js(
|
|
'document.documentElement.dataset.reviewNoticeMessage.includes("回顾未能完成")'
|
|
)
|
|
)
|
|
await writeFile(
|
|
join(artifacts, 'manual-review-error-390.png'),
|
|
(await win.webContents.capturePage()).toPNG()
|
|
)
|
|
await js('document.querySelector("#supervisor-tab-settings").click()')
|
|
await wait('!!document.querySelector(".heartbeat-settings")')
|
|
assert(await js('!document.querySelector("#supervisor-panel-settings .scope-badge")'))
|
|
assert.equal(await js('document.querySelectorAll("#supervisor-panel-settings [role=tablist]").length'), 1, 'Only the settings section tabs')
|
|
assert(await js('!document.querySelector("#heartbeat-panel-plans")'), 'Plans must not live in settings')
|
|
await js('document.querySelector("#supervisor-tab-plans").click()')
|
|
await js('document.querySelector(".heartbeat-settings__intro button").focus(); document.querySelector(".heartbeat-settings__intro button").click()')
|
|
await wait('!!document.querySelector("[role=dialog]")')
|
|
assert(await js('document.activeElement === document.querySelector("[role=dialog] input")'), 'Plan initial focus')
|
|
await js(`(() => {
|
|
const select = document.querySelector('[aria-label="监督频率"]');
|
|
select.value = 'weekly'; select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
})()`)
|
|
await wait('!!document.querySelector("[aria-label=监督星期]")')
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const width of [1440, 1024, 390]) {
|
|
win.setContentSize(width, 1100)
|
|
await settle()
|
|
const settings = await js(`(() => {
|
|
const root = document.querySelector('[role=dialog]');
|
|
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth,
|
|
clientWidth: root.clientWidth, scrollWidth: root.scrollWidth,
|
|
fields: [...root.querySelectorAll('input, select')].filter(e => e.getClientRects().length).map(e => {
|
|
const r = e.getBoundingClientRect();
|
|
return { left: r.left, right: r.right, height: r.height };
|
|
}), text: root.textContent };
|
|
})()`)
|
|
assert(settings.pageWidth <= width && settings.scrollWidth <= settings.clientWidth, 'Settings overflow')
|
|
assert(settings.fields.every(r => r.left >= 0 && r.right <= width && r.height >= 28), 'Clipped settings fields')
|
|
reports.push({ scenario: 'automatic-supervision-modal', theme, ...settings })
|
|
await writeFile(join(artifacts, `plan-modal-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
if (width === 390) {
|
|
win.setContentSize(390, 720)
|
|
await js('document.querySelector("[role=dialog] .heartbeat-settings__submit").scrollIntoView({block:"end"})')
|
|
await settle()
|
|
assert(await js(`(() => {
|
|
const button = document.querySelector('[role=dialog] .heartbeat-settings__submit');
|
|
const r = button.getBoundingClientRect();
|
|
return r.left >= 16 && r.right <= innerWidth - 16 && r.bottom <= innerHeight && button.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
|
|
})()`), 'Short modal save button must be reachable')
|
|
await writeFile(join(artifacts, `plan-modal-${theme}-390-short.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
}
|
|
await js('document.querySelector("[role=dialog]").dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))')
|
|
await wait('document.querySelector("[role=dialog]").textContent.includes("放弃未保存")')
|
|
await js('document.querySelector("[role=dialog] .danger-solid").click()')
|
|
await wait('!document.querySelector("[role=dialog]")')
|
|
assert(await js('document.activeElement === document.querySelector(".heartbeat-settings__intro button")'), 'Plan focus restored')
|
|
await js('document.querySelector("#supervisor-tab-activity").click()')
|
|
await wait('document.querySelectorAll(".supervisor-activity__item").length === 3')
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const width of [1440, 1024, 390]) {
|
|
win.setContentSize(width, width === 390 ? 720 : 1100)
|
|
await settle()
|
|
const activity = await js(`(() => {
|
|
const root = document.querySelector('.supervisor-activity');
|
|
const tabs = document.querySelector('#supervisor-tab-activity').closest('[role=tablist]');
|
|
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth,
|
|
clientWidth: root.clientWidth, scrollWidth: root.scrollWidth,
|
|
tabsHeight: tabs.getBoundingClientRect().height,
|
|
fields: [...root.querySelectorAll('dd, button')].map(e => {
|
|
const r = e.getBoundingClientRect(); return { left: r.left, right: r.right };
|
|
}), text: root.textContent };
|
|
})()`)
|
|
assert(activity.pageWidth <= width && activity.scrollWidth <= activity.clientWidth, 'Activity overflow')
|
|
assert(activity.tabsHeight >= 32, 'Activity tabs must not collapse')
|
|
assert(activity.fields.every(r => r.left >= 0 && r.right <= width), 'Clipped activity metadata or actions')
|
|
assert(activity.text.includes('运行中') && activity.text.includes('失败') && activity.text.includes('已完成'), 'Missing execution states')
|
|
reports.push({ scenario: 'supervisor-activity', theme, ...activity })
|
|
await writeFile(join(artifacts, `activity-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?menu=1')
|
|
await wait('!!document.querySelector(".supervisor-workspace__recap")')
|
|
win.show()
|
|
win.focus()
|
|
for (const theme of ['light', 'dark']) {
|
|
await js(`document.documentElement.dataset.theme = '${theme}'`)
|
|
for (const width of [1440, 390]) {
|
|
win.setContentSize(width, width === 390 ? 720 : 1100)
|
|
for (const tab of ['overview', 'graph', 'plans', 'settings', 'activity']) {
|
|
await js(`document.querySelector('#supervisor-tab-${tab}').click(); document.querySelector('.page-shell').scrollTop = 0`)
|
|
await settle()
|
|
const menuLayout = await js(`(() => {
|
|
const panel = document.querySelector('#supervisor-panel-${tab}');
|
|
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth,
|
|
panelWidth: panel.clientWidth, scrollWidth: panel.scrollWidth,
|
|
tabs: [...document.querySelectorAll('[role=tab][id^="supervisor-tab-"]')].map(e => e.textContent),
|
|
nestedMenus: panel.querySelectorAll('[role=tablist]').length,
|
|
reports: !!panel.querySelector('#heartbeat-reports-title'),
|
|
reportWidth: panel.querySelector('#heartbeat-panel-history > section')?.getBoundingClientRect().width,
|
|
suggestions: !!panel.querySelector('#supervision-suggestions-title'),
|
|
suggestionItems: panel.querySelectorAll('.supervision-suggestions__list > li').length,
|
|
audit: !!panel.querySelector('#heartbeat-runs-title'),
|
|
plans: !!panel.querySelector('#heartbeat-panel-plans'),
|
|
settingsBadge: !!panel.querySelector('.scope-badge'),
|
|
headings: [...panel.querySelectorAll('h2')].map(e => e.textContent),
|
|
refresh: [...panel.querySelectorAll('button')].some(e => e.textContent.includes('刷新')) };
|
|
})()`)
|
|
assert.deepEqual(menuLayout.tabs, ['工作回顾', '故事线图谱', '智能心跳', '活动记录', '设置'])
|
|
assert.equal(menuLayout.nestedMenus, tab === 'graph' || tab === 'settings' ? 1 : 0)
|
|
assert(menuLayout.pageWidth <= width && menuLayout.scrollWidth <= menuLayout.panelWidth, 'Menu panel overflow')
|
|
assert.equal(menuLayout.reports, tab === 'plans')
|
|
if (tab === 'plans') assert(Math.abs(menuLayout.reportWidth - menuLayout.panelWidth) <= 1, 'Reports must use full reading width')
|
|
assert.equal(menuLayout.suggestions, tab === 'plans')
|
|
assert.equal(menuLayout.suggestionItems, tab === 'plans' ? 2 : 0)
|
|
assert.equal(menuLayout.audit, false, 'Run audit belongs to Activity, not Smart heartbeat')
|
|
assert.equal(menuLayout.plans, tab === 'plans')
|
|
if (tab === 'settings') {
|
|
assert(!menuLayout.settingsBadge && !menuLayout.refresh, 'Redundant settings badge/refresh')
|
|
assert(menuLayout.headings.includes('回顾算法') && menuLayout.headings.includes('模型与运行限制'))
|
|
}
|
|
reports.push({ scenario: 'menu-placement', theme, tab, ...menuLayout })
|
|
await writeFile(join(artifacts, `menu-${tab}-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
|
|
const anchors = tab === 'settings' ? ['.supervisor-settings [role=tabpanel]:not([hidden]) form']
|
|
: tab === 'plans' ? ['#heartbeat-panel-overview', '.supervision-suggestions', '#heartbeat-panel-history'] : []
|
|
for (const [index, anchor] of anchors.entries()) {
|
|
await js(`document.querySelector('${anchor}').scrollIntoView({block:'start'})`)
|
|
await settle()
|
|
await writeFile(join(artifacts, `menu-${tab}-${theme}-${width}-section-${index}.png`), (await win.webContents.capturePage()).toPNG())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await js('document.querySelector("#supervisor-tab-plans").click()')
|
|
await js('[...document.querySelectorAll(".heartbeat-settings__actions button")].find(b => b.textContent === "执行记录").click()')
|
|
await wait('document.querySelector(".supervisor-activity select")?.value === "plan" && document.querySelectorAll(".supervisor-activity__item").length === 2')
|
|
await js('document.querySelector(".page-shell").scrollTop = 0')
|
|
await settle()
|
|
await writeFile(join(artifacts, 'activity-exact-plan.png'), (await win.webContents.capturePage()).toPNG())
|
|
await js('document.querySelector("#supervisor-tab-settings").focus()')
|
|
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Left' })
|
|
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Left' })
|
|
await settle()
|
|
assert(await js('document.activeElement.id === "supervisor-tab-activity" && document.activeElement.getAttribute("aria-selected") === "true"'), 'Native keyboard tab navigation')
|
|
assert.deepEqual(errors, [])
|
|
await writeFile(
|
|
join(artifacts, 'measurements.json'),
|
|
JSON.stringify(reports, null, 2)
|
|
)
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
artifacts,
|
|
cases: reports.map(({ texts, tokens, ...report }) => ({
|
|
...report,
|
|
svgTexts: texts?.length,
|
|
resolvedTokens: tokens && Object.keys(tokens).length
|
|
})),
|
|
errors
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
)
|
|
win.destroy()
|
|
app.quit()
|
|
})
|
|
.catch((error) => {
|
|
console.error(error)
|
|
app.exit(1)
|
|
})
|