Files
ycode/scripts/verify-project-page.cjs
T

277 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/project-page-'))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let log = ''
child.stdout.on('data', bytes => { log += bytes; process.stdout.write(bytes) })
child.stderr.on('data', bytes => { log += bytes })
const timer = setTimeout(() => child.kill(), 90000)
child.once('exit', async code => { clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), log); process.exitCode = code ?? 1 })
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron')
const { pathToFileURL } = require('node:url')
const directory = process.env.YCODE_QA_DIRECTORY
app.setPath('home', path.join(directory, 'home'))
;(async () => {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
let win
const wait = async source => {
for (let i = 0; i < 300; i++) {
win = BrowserWindow.getAllWindows().find(item => item.getTitle() === 'YCode')
if (win && !win.webContents.isLoading() && await win.webContents.executeJavaScript(source)) return
await new Promise(resolve => setTimeout(resolve, 30))
}
throw Error(`Timeout: ${source}`)
}
await wait('!!window.ycode?.projects')
win.setContentSize(1336, 902)
await wait('innerHeight === 902')
await new Promise(resolve => setTimeout(resolve, 250))
const errors = []
win.webContents.on('console-message', (_event, level, message) => { if (level >= 3) errors.push(message) })
const read = source => win.webContents.executeJavaScript(source)
const dialogSurfaces = {}
const dialogBackgrounds = {}
const pixelAt = (image, point, viewport) => {
const size = image.getSize(), bitmap = image.toBitmap()
const x = Math.floor(point[0] * size.width / viewport[0]), y = Math.floor(point[1] * size.height / viewport[1])
const offset = (y * size.width + x) * 4
return [bitmap[offset+2],bitmap[offset+1],bitmap[offset]]
}
const rememberDialogBackground = async theme => {
// Theme surfaces transition for 150–200ms. Compare stable pixels, rather
// than a halfway theme frame against the fully transitioned modal frame.
await read(`Promise.all(document.getAnimations().filter(animation =>
Number.isFinite(animation.effect?.getComputedTiming().endTime)).map(animation => animation.finished.catch(()=>{})))`)
const region = await read(`(() => { const r=document.querySelector('.conversation-section-label-tasks').getBoundingClientRect();
return {point:[r.left+r.width/2,r.top+r.height/2],viewport:[innerWidth,innerHeight]} })()`)
dialogBackgrounds[theme] = {...region,pixel:pixelAt(await win.webContents.capturePage(),region.point,region.viewport)}
}
const verifyDialogSurface = async theme => {
await read('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const surface = await read(`(() => {
const overlay = document.querySelector('.collab-modal__overlay')
const dialog = overlay.querySelector('.collab-modal__container')
const backdrop = overlay.querySelector('.collab-modal__backdrop')
const box = element => { const r = element.getBoundingClientRect(); return [r.x,r.y,r.width,r.height] }
const colors = Object.fromEntries(['container','header','body','footer'].map(part => [part,getComputedStyle(overlay.querySelector('.collab-modal__'+part)).backgroundColor]))
const dr = dialog.getBoundingClientRect()
const points = [[40,110],[innerWidth-35,180],[dr.left+25,dr.top+20],[dr.right-40,dr.bottom-30]]
return { theme:document.documentElement.dataset.theme, viewport:[innerWidth,innerHeight], overlay:box(overlay), dialog:box(dialog), colors,
mask:getComputedStyle(overlay).backgroundColor,zIndex:getComputedStyle(overlay).zIndex,
hitTargets:points.map(([x,y]) => { const hit=document.elementFromPoint(x,y); return {point:[x,y],className:hit?.className,
blocked:hit===backdrop || dialog.contains(hit)} }),
inputBackground:getComputedStyle(document.querySelector('#yc-project-name')).backgroundColor }
})()`)
const image = await win.webContents.capturePage()
const background = dialogBackgrounds[theme]
const alpha = Number(surface.mask.match(/,\s*([\d.]+)\)$/)?.[1] ?? 1)
surface.backgroundSample = {point:background.point,before:background.pixel,
expected:background.pixel.map(channel=>Math.round(channel*(1-alpha))),
actual:pixelAt(image,background.point,background.viewport)}
dialogSurfaces[theme] = surface
await fs.writeFile(path.join(directory, `project-dialog-${theme}.png`), image.toPNG())
await fs.writeFile(path.join(directory, 'dialog-surfaces.json'), JSON.stringify(dialogSurfaces,null,2))
const opaque = value => /^rgb\(/.test(value) || /, 1\)$/.test(value)
assert.ok(Object.values(surface.colors).every(opaque), `${theme}: dialog regions must be opaque`)
assert.deepEqual(surface.overlay, [0,0,...surface.viewport], `${theme}: mask covers the whole viewport`)
assert.ok(surface.hitTargets.every(item=>item.blocked), `${theme}: background chrome must stay below the modal mask`)
assert.ok(Number(surface.zIndex)>=101, `${theme}: modal owns a layer above the page chrome`)
assert.ok(surface.backgroundSample.actual.every((channel,index)=>Math.abs(channel-surface.backgroundSample.expected[index])<=2),
`${theme}: sticky sidebar headings must be visually dimmed by the mask`)
assert.equal(surface.dialog[2], 640)
}
const connector = await read(`window.ycode.mcp.save({name:'项目连接器',transport:'stdio',command:'node',args:['--version'],enabled:false})`)
const enter = async () => {
await wait('!!document.querySelector(".conversation-list-tab-button-more")')
await read('document.querySelector(".conversation-list-tab-button-more").click()')
await wait('!!document.querySelector(".yc-project-more button")')
await read('document.querySelector(".yc-project-more button").click()')
await wait('!!document.querySelector(".landing-new-btn")')
}
await enter()
await wait('!!document.querySelector(".project-grid__empty")')
assert.ok((await read('document.querySelector(".project-grid__empty").textContent')).includes('还没有项目'))
assert.equal(await read('document.querySelectorAll(".landing-template-card").length'), 5)
await rememberDialogBackground('light')
await read('document.querySelector(".landing-new-btn").click()')
await wait('!!document.querySelector("#yc-project-name")')
await verifyDialogSurface('light')
assert.equal(await read('document.querySelector(".collab-modal__footer button:last-child").disabled'), true)
await read(`(() => { const input = document.querySelector('#yc-project-name'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, '项目验收'); input.dispatchEvent(new Event('input', { bubbles: true })); })()`)
await wait('!document.querySelector(".collab-modal__footer button:last-child").disabled')
await read('document.querySelector("[data-capability-kind=connectors] .cp-row__action").click()')
await wait('!!document.querySelector("[data-capability-kind=connectors] input[type=checkbox]")')
await read('document.querySelector("[data-capability-kind=connectors] input[type=checkbox]").click()')
await wait('document.querySelector("[data-capability-kind=connectors] .cp-chip__name")?.textContent === "项目连接器"')
await read('document.querySelector("[data-capability-kind=connectors] .cp-chip__x").click()')
await wait('!document.querySelector("[data-capability-kind=connectors] .cp-chip")')
await read('document.querySelector("[data-capability-kind=connectors] input[type=checkbox]").click()')
await read('document.querySelector(".collab-modal__footer button:last-child").click()')
await wait('!document.querySelector(".collab-modal__container") && document.querySelector(".project-grid__card-name-text")?.textContent === "项目验收"')
const records = JSON.parse(await fs.readFile(path.join(directory, 'data', 'projects.json'), 'utf8'))
assert.equal(records.length, 1); assert.equal(records[0].name, '项目验收')
assert.deepEqual(records[0].connectors, [connector.id])
const task = await read(`window.ycode.session.create('项目任务验收',{projectId:${JSON.stringify('REPLACE_PROJECT_ID')}})`.replace(JSON.stringify('REPLACE_PROJECT_ID'), JSON.stringify(records[0].projectId)))
assert.equal(task.projectId, records[0].projectId)
const invalidProjectRejected = await read(`window.ycode.session.create('无效项目任务',{projectId:'missing-project'}).then(()=>false,()=>true)`)
assert.equal(invalidProjectRejected, true)
const loaded = new Promise(resolve => win.webContents.once('did-finish-load', resolve))
win.webContents.reload()
await loaded
await wait('!!window.ycode?.projects')
await enter()
const restoredTask = await read(`window.ycode.session.list().then(items=>items.find(item=>item.id===${JSON.stringify('REPLACE_TASK_ID')}))`.replace(JSON.stringify('REPLACE_TASK_ID'), JSON.stringify(task.id)))
assert.equal(restoredTask.projectId, records[0].projectId)
await wait('document.querySelector(".project-grid__card-name-text")?.textContent === "项目验收"')
await read('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
await read(`(() => { const input = document.querySelector('input[aria-label="搜索项目"]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, '不存在'); input.dispatchEvent(new Event('input', { bubbles: true })); })()`)
await wait('document.querySelector(".project-grid__empty")?.textContent.includes("没有匹配的项目")')
await read(`(() => { const input = document.querySelector('input[aria-label="搜索项目"]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, ''); input.dispatchEvent(new Event('input', { bubbles: true })); })()`)
await wait('document.querySelector(".project-grid__card-name-text")?.textContent === "项目验收"')
await read('document.querySelector(".project-grid__card").click()')
await wait('!!document.querySelector(".project-detail-view")')
await read(`Array.from(document.querySelectorAll('.project-detail-view__top-tab')).find(item => item.textContent.trim() === '任务').click()`)
await wait('document.querySelector(".task-item")?.dataset.taskStatus === "idle"')
await read('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const detailGeometry = await read(`(() => {
const rect = selector => { const el = document.querySelector(selector); if (!el) return null; const r = el.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height} }
return {
viewport: {width: innerWidth, height: innerHeight},
shell: rect('.app-shell'),
surface: rect('.yc-project-surface'),
detail: rect('.project-detail-view'),
topbar: rect('.project-detail-view > .workbuddy-topbar'),
header: rect('.project-detail-view__header'),
body: rect('.project-detail-view__body'),
main: rect('.project-detail-view__main'),
panel: rect('.project-detail-view__panel'),
composer: rect('.project-detail-view__project-composer'),
taskList: rect('.project-detail-view__task-list'),
toolbar: rect('.project-detail-view__task-toolbar'),
configCards: document.querySelectorAll('.context-panel .wb-config-card').length,
configKinds: document.querySelectorAll('[data-config-kind]').length
}
})()`)
console.log(`DETAIL_GEOMETRY ${JSON.stringify(detailGeometry)}`)
assert.equal(detailGeometry.topbar.height, 64)
assert.equal(detailGeometry.header, null)
assert.equal(detailGeometry.viewport.height, 902)
assert.equal(detailGeometry.body.height, 808)
assert.equal(detailGeometry.body.y, detailGeometry.topbar.y + detailGeometry.topbar.height)
assert.equal(detailGeometry.body.y + detailGeometry.body.height, detailGeometry.viewport.height)
assert.equal(detailGeometry.panel.width, 320)
assert.equal(detailGeometry.main.width + detailGeometry.panel.width, detailGeometry.body.width)
assert.equal(detailGeometry.composer.height, 148)
assert.equal(detailGeometry.composer.y, 730)
assert.equal(detailGeometry.composer.y + detailGeometry.composer.height, detailGeometry.viewport.height - 24)
assert.equal(detailGeometry.configCards, 5)
assert.equal(detailGeometry.configKinds, 3)
await read('document.querySelectorAll(".project-task-panel__filter-trigger")[0].click()')
await wait('document.querySelectorAll(".wb-select__option").length === 3')
assert.deepEqual(
await read('Array.from(document.querySelectorAll(".wb-select__option")).map(el => el.textContent.trim())'),
['全部任务', '个人任务', '协作任务'],
)
await read('document.querySelectorAll(".wb-select__option")[1].click()')
await wait('!document.querySelector(".wb-select__popover")')
assert.equal(await read('document.querySelectorAll(".task-item").length'), 1)
await read('document.querySelectorAll(".project-task-panel__filter-trigger")[0].click()')
await wait('document.querySelectorAll(".wb-select__option").length === 3')
await read('document.querySelectorAll(".wb-select__option")[2].click()')
await wait('!document.querySelector(".wb-select__popover") && !document.querySelector(".task-item")')
await read('document.querySelectorAll(".project-task-panel__filter-trigger")[0].click()')
await wait('document.querySelectorAll(".wb-select__option").length === 3')
await read('document.querySelectorAll(".wb-select__option")[0].click()')
await wait('!document.querySelector(".wb-select__popover") && document.querySelectorAll(".task-item").length === 1')
await read('document.querySelectorAll(".project-task-panel__filter-trigger")[1].click()')
await wait('document.querySelectorAll(".wb-select__option").length === 4')
assert.deepEqual(
await read('Array.from(document.querySelectorAll(".wb-select__option")).map(el => el.textContent.trim())'),
['全部来源', '手动创建', '定时任务', '本地'],
)
await read('document.querySelectorAll(".wb-select__option")[3].click()')
await wait('!document.querySelector(".wb-select__popover") && document.querySelectorAll(".task-item").length === 1')
await read('document.querySelectorAll(".project-task-panel__filter-trigger")[1].click()')
await wait('document.querySelectorAll(".wb-select__option").length === 4')
await read('document.querySelectorAll(".wb-select__option")[1].click()')
await wait('!document.querySelector(".wb-select__popover") && !document.querySelector(".task-item")')
await read('document.querySelectorAll(".project-task-panel__filter-trigger")[1].click()')
await wait('document.querySelectorAll(".wb-select__option").length === 4')
await read('document.querySelectorAll(".wb-select__option")[2].click()')
await wait('!document.querySelector(".wb-select__popover") && !document.querySelector(".task-item")')
await read('document.querySelectorAll(".project-task-panel__filter-trigger")[1].click()')
await wait('document.querySelectorAll(".wb-select__option").length === 4')
await read('document.querySelectorAll(".wb-select__option")[0].click()')
await wait('!document.querySelector(".wb-select__popover") && document.querySelectorAll(".task-item").length === 1')
await fs.writeFile(path.join(directory, 'project-detail-tasks.png'), (await win.webContents.capturePage()).toPNG())
const initialTaskStatus = await read('document.querySelector(".task-item").dataset.taskStatus')
assert.equal(initialTaskStatus, 'idle')
assert.equal(await read('document.querySelectorAll("[data-config-kind]").length'), 3)
await read('document.querySelector("[data-config-kind=connectors]").click()')
await wait('!!document.querySelector(".project-capability-editor")')
await wait('document.querySelector(".project-capability-editor")?.textContent.includes("项目连接器")')
assert.equal(await read('document.querySelector(".project-capability-editor__choice input[type=checkbox]").checked'), true)
await read('document.querySelector(".project-capability-editor__choice input[type=checkbox]").click()')
await wait('!document.querySelector(".project-capability-editor__choice input[type=checkbox]").checked')
await read('document.querySelector(".project-capability-editor .project-instruction-drawer__save").click()')
await wait('!document.querySelector(".project-capability-editor")')
const editedProject = await read(`window.ycode.projects.list().then(items => items.find(item => item.projectId === ${JSON.stringify(records[0].projectId)}))`)
assert.deepEqual(editedProject.connectors, [])
await read('document.querySelector(".project-detail-view__panel .wb-config-card .wb-config-card__hit-area").click()')
await wait('!!document.querySelector(".project-instruction-drawer__textarea")')
await read(`(() => { const input = document.querySelector(".project-instruction-drawer__textarea"); Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(input, '父对象同步测试'); input.dispatchEvent(new Event('input', { bubbles: true })); })()`)
await wait('!document.querySelector(".project-instruction-drawer__save").disabled')
await read('document.querySelector(".project-instruction-drawer__save").click()')
await wait('!document.querySelector(".project-instruction-drawer") && document.querySelector(".project-detail-view__panel").textContent.includes("父对象同步测试")')
await read('document.querySelector(".project-detail-view__breadcrumb button").click()')
await wait('document.querySelector(".project-grid__card-name-text")?.textContent === "项目验收"')
const geometry = await read(`(() => { const rect = selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height } }; return { topbar:rect('.workbuddy-topbar'), header:rect('.landing-header'), card:rect('.project-grid__card'), icon:rect('.project-grid__card-icon'), time:document.querySelector('.project-grid__card-desc').textContent } })()`)
assert.equal(geometry.topbar.height, 64)
assert.equal(geometry.header.y, geometry.topbar.y + 64 + 56)
assert.equal(geometry.card.height, 80)
assert.equal(geometry.icon.width, 36)
assert.ok(geometry.time.startsWith('添加于 '))
await fs.writeFile(path.join(directory, 'project-page.png'), (await win.webContents.capturePage()).toPNG())
await read(`document.documentElement.dataset.theme = 'dark'`)
await read('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const dark = await read(`(() => { const card = getComputedStyle(document.querySelector('.project-grid__card')); const hero = getComputedStyle(document.querySelector('.landing-hero')); return { card:card.backgroundColor, text:card.color, heroFilter:hero.filter, heroOpacity:hero.opacity } })()`)
assert.notEqual(dark.card, 'rgb(255, 255, 255)')
assert.ok(dark.heroFilter.includes('invert(1)'))
assert.equal(dark.heroOpacity, '0.88')
await fs.writeFile(path.join(directory, 'project-page-dark.png'), (await win.webContents.capturePage()).toPNG())
await rememberDialogBackground('dark')
await read('document.querySelector(".landing-new-btn").click()')
await wait('!!document.querySelector(".collab-modal__container")')
await verifyDialogSurface('dark')
const dialog = await read(`(() => { const el = document.querySelector('.collab-modal__container'); const r=el.getBoundingClientRect(); return {width:r.width, background:getComputedStyle(el).backgroundColor, input:document.querySelector('#yc-project-name').maxLength} })()`)
assert.equal(dialog.width, 640)
assert.notEqual(dialog.background, 'rgb(255, 255, 255)')
assert.equal(dialog.input, 15)
await wait('!document.querySelector("button[aria-label=选择项目模板]").disabled')
await read(`(() => { const input = document.querySelector('#yc-project-name'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, '保留项目名'); input.dispatchEvent(new Event('input', {bubbles:true})); })()`)
await read('document.querySelector("button[aria-label=选择项目模板]").click()')
await wait('document.querySelectorAll(".wb-select__option").length === 6')
await read(`document.querySelector('.wb-select__option').dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',bubbles:true}))`)
await wait('document.querySelector("#yc-project-instructions").value.includes("角色") && document.querySelectorAll("[data-capability-kind=experts] .cp-chip").length > 0')
assert.equal(await read('document.querySelector("#yc-project-name").value'), '保留项目名')
await read('document.querySelector("button[aria-label=选择项目模板]").click()')
await wait('document.querySelectorAll(".wb-select__option").length === 6')
await read(`Array.from(document.querySelectorAll('.wb-select__option')).find(el => el.textContent.trim() === '自定义').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
await wait('document.querySelector("#yc-project-instructions").value === "" && document.querySelectorAll(".cp-chip").length === 0')
assert.equal(await read('document.querySelector("#yc-project-name").value'), '保留项目名')
await fs.writeFile(path.join(directory, 'project-dialog-dark.png'), (await win.webContents.capturePage()).toPNG())
assert.deepEqual(errors, [])
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', records, initialTaskStatus, detailGeometry, editedProject, geometry, dark, dialog, dialogSurfaces, rendererErrors: errors }, null, 2))
console.log(`PROJECT_PAGE_PASS ${directory}`)
app.exit(0)
})().catch(error => { console.error(error); app.exit(1) })
}