Files
BoosAPI/desktop/libs/markstream-vue/scripts/e2e-diff-theme-switch.mjs
T
boos e0a5f57793 feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
2026-06-22 11:32:57 +08:00

279 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const playgroundDir = path.join(repoRoot, 'playground')
const host = '127.0.0.1'
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4190, end = 4235) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeout = 30000) {
const start = Date.now()
while (Date.now() - start < timeout) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function colorLuminance(color) {
const channels = String(color || '').match(/\d+(?:\.\d+)?/g)
if (!channels || channels.length < 3)
return null
const [r, g, b] = channels.slice(0, 3).map(Number)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
async function snapshot(page) {
return page.evaluate(() => {
function visibleTextElements(selector) {
return Array.from(document.querySelectorAll(selector))
.filter((node) => {
if (!(node instanceof HTMLElement))
return false
const text = node.textContent?.trim() ?? ''
if (!text)
return false
const style = window.getComputedStyle(node)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number.parseFloat(style.opacity || '1') > 0.05
})
.map(node => node.textContent.trim())
}
const container = document.querySelector('.code-block-container')
const header = document.querySelector('.code-block-header')
const preview = document.querySelector('[data-testid="diff-theme-preview"]')
const diffRoot = document.querySelector('.stream-monaco-diff-root')
const editorBackground = diffRoot?.querySelector('.monaco-editor .monaco-editor-background')
const containerStyle = container instanceof HTMLElement ? window.getComputedStyle(container) : null
const headerStyle = header instanceof HTMLElement ? window.getComputedStyle(header) : null
const previewStyle = preview instanceof HTMLElement ? window.getComputedStyle(preview) : null
const editorStyle = editorBackground instanceof HTMLElement ? window.getComputedStyle(editorBackground) : null
const visibleNativeCompactTexts = visibleTextElements('.diff-hidden-lines-compact .text')
const visibleNativeCenterTexts = Array.from(document.querySelectorAll('.diff-hidden-lines .center'))
.filter((node) => {
if (!(node instanceof HTMLElement))
return false
if (node.classList.contains('stream-monaco-clickable'))
return false
const style = window.getComputedStyle(node)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number.parseFloat(style.opacity || '1') > 0.05
})
.map(node => (node instanceof HTMLElement ? node.textContent.trim() : ''))
.filter(Boolean)
return {
pageMode: document.documentElement.dataset.themeMode || '',
containerBg: containerStyle?.backgroundColor || '',
headerBg: headerStyle?.backgroundColor || '',
previewBg: previewStyle?.backgroundColor || '',
editorBg: editorStyle?.backgroundColor || '',
rootClasses: diffRoot instanceof HTMLElement ? Array.from(diffRoot.classList) : [],
isDarkClass: container instanceof HTMLElement ? container.classList.contains('is-dark') : false,
metadataLabels: Array.from(document.querySelectorAll('.stream-monaco-unchanged-metadata-label')).map(node => node.textContent?.trim() || ''),
visibleNativeCompactTexts,
visibleNativeCenterTexts,
visibleHiddenLinesInBody: /hidden lines/i.test(document.body.textContent || ''),
}
})
}
async function captureTransition(page, mode) {
await page.click(`[data-theme-toggle="${mode}"]`)
const frames = []
for (let i = 0; i < 72; i++) {
await page.waitForTimeout(16)
frames.push(await snapshot(page))
}
return {
frames,
final: frames[frames.length - 1] ?? await snapshot(page),
}
}
async function main() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
const vite = spawn(
'pnpm',
['-C', playgroundDir, 'dev', '--host', host, '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
},
)
const logs = []
vite.stdout.on('data', chunk => logs.push(String(chunk)))
vite.stderr.on('data', chunk => logs.push(String(chunk)))
const cleanup = () => killProcessTree(vite)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({
viewport: { width: 1440, height: 1200 },
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error')
consoleErrors.push(message.text())
})
await page.goto(`http://${host}:${port}/diff-theme-regression`, {
waitUntil: 'networkidle',
})
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
await page.waitForSelector('.stream-monaco-unchanged-metadata-label', { timeout: 30000 })
await page.waitForTimeout(400)
const initial = await snapshot(page)
const toLight = await captureTransition(page, 'light')
const toDark = await captureTransition(page, 'dark')
await browser.close()
const initialLum = colorLuminance(initial.containerBg)
const lightLum = colorLuminance(toLight.final.containerBg)
const darkLum = colorLuminance(toDark.final.containerBg)
const frames = [...toLight.frames, ...toDark.frames]
const anyHiddenFlash = frames.some(frame =>
frame.visibleHiddenLinesInBody
|| frame.visibleNativeCompactTexts.length > 0
|| frame.visibleNativeCenterTexts.some(text => /hidden lines/i.test(text)),
)
const ok = (initialLum != null && initialLum < 90)
&& (lightLum != null && lightLum > 170)
&& (darkLum != null && darkLum < 90)
&& initial.containerBg !== toLight.final.containerBg
&& toLight.final.containerBg !== toDark.final.containerBg
&& toLight.final.rootClasses.includes('stream-monaco-diff-appearance-light')
&& toDark.final.rootClasses.includes('stream-monaco-diff-appearance-dark')
&& !anyHiddenFlash
&& pageErrors.length === 0
&& consoleErrors.length === 0
const result = {
ok,
colors: {
initial: initial.containerBg,
light: toLight.final.containerBg,
dark: toDark.final.containerBg,
},
luminance: {
initial: initialLum,
light: lightLum,
dark: darkLum,
},
initial,
finalLight: toLight.final,
finalDark: toDark.final,
anyHiddenFlash,
pageErrors,
consoleErrors,
}
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error('Recent Vite logs:\n', logs.slice(-60).join(''))
process.exit(1)
}
}
finally {
cleanup()
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})