e0a5f57793
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.
474 lines
16 KiB
JavaScript
474 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from 'node:child_process'
|
|
import { existsSync, lstatSync, readlinkSync, rmSync, symlinkSync } 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 markstreamReactDir = path.join(repoRoot, 'packages/markstream-react')
|
|
const host = '127.0.0.1'
|
|
|
|
const EXPORT_NAMES = [
|
|
'NodeRenderer',
|
|
'AdmonitionNode',
|
|
'BlockquoteNode',
|
|
'CheckboxNode',
|
|
'CodeBlockNode',
|
|
'ReactCodeBlockNode',
|
|
'MarkdownCodeBlockNode',
|
|
'PreCodeNode',
|
|
'D2BlockNode',
|
|
'MermaidBlockNode',
|
|
'InfographicBlockNode',
|
|
'DefinitionListNode',
|
|
'EmojiNode',
|
|
'EmphasisNode',
|
|
'StrongNode',
|
|
'StrikethroughNode',
|
|
'HighlightNode',
|
|
'InsertNode',
|
|
'SubscriptNode',
|
|
'SuperscriptNode',
|
|
'FootnoteNode',
|
|
'FootnoteReferenceNode',
|
|
'FootnoteAnchorNode',
|
|
'HardBreakNode',
|
|
'HeadingNode',
|
|
'ParagraphNode',
|
|
'HtmlBlockNode',
|
|
'HtmlInlineNode',
|
|
'ImageNode',
|
|
'InlineCodeNode',
|
|
'LinkNode',
|
|
'ListItemNode',
|
|
'ListNode',
|
|
'MathBlockNode',
|
|
'MathInlineNode',
|
|
'ReferenceNode',
|
|
'TableNode',
|
|
'TextNode',
|
|
'ThematicBreakNode',
|
|
'Tooltip',
|
|
'HtmlPreviewFrame',
|
|
'VmrContainerNode',
|
|
'FallbackComponent',
|
|
]
|
|
|
|
function parseArgs(argv) {
|
|
const args = {}
|
|
for (let i = 2; i < argv.length; i++) {
|
|
const current = argv[i]
|
|
const next = argv[i + 1]
|
|
if (current === '--playground' && next) {
|
|
args.playground = next
|
|
i++
|
|
}
|
|
else if (current === '--version' && next) {
|
|
args.version = next
|
|
i++
|
|
}
|
|
}
|
|
if (!args.playground || !args.version)
|
|
throw new Error('Usage: node scripts/e2e-next-ssr.mjs --playground <dir> --version <next14|next15>')
|
|
return args
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function isPortOpen(port, listenHost = host) {
|
|
return new Promise((resolve) => {
|
|
const socket = net.createConnection({ host: listenHost, port })
|
|
socket.on('connect', () => {
|
|
socket.end()
|
|
resolve(true)
|
|
})
|
|
socket.on('error', () => {
|
|
socket.destroy()
|
|
resolve(false)
|
|
})
|
|
})
|
|
}
|
|
|
|
async function findFreePort(start = 3400, end = 3499) {
|
|
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 = 120000) {
|
|
const startedAt = Date.now()
|
|
while (Date.now() - startedAt < timeout) {
|
|
if (await isPortOpen(port))
|
|
return
|
|
await sleep(200)
|
|
}
|
|
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',
|
|
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
].filter(Boolean)
|
|
|
|
for (const candidate of candidates) {
|
|
if (existsSync(candidate)) {
|
|
return {
|
|
executablePath: candidate,
|
|
headless: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
channel: 'chrome',
|
|
headless: true,
|
|
}
|
|
}
|
|
|
|
function runCommand(command, args, cwd) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
cwd,
|
|
env: {
|
|
...process.env,
|
|
CI: '1',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
})
|
|
|
|
let output = ''
|
|
const append = (chunk) => {
|
|
output += String(chunk)
|
|
if (output.length > 12000)
|
|
output = output.slice(output.length - 12000)
|
|
}
|
|
|
|
child.stdout.on('data', append)
|
|
child.stderr.on('data', append)
|
|
child.on('error', reject)
|
|
child.on('close', (code) => {
|
|
if (code === 0)
|
|
resolve(output)
|
|
else
|
|
reject(new Error(`Command failed: ${command} ${args.join(' ')}\n${output}`))
|
|
})
|
|
})
|
|
}
|
|
|
|
function syncMarkstreamReactPeers(playgroundDir) {
|
|
const peerNames = ['react', 'react-dom']
|
|
const originalLinks = []
|
|
|
|
for (const peerName of peerNames) {
|
|
const packagePeerPath = path.join(markstreamReactDir, 'node_modules', peerName)
|
|
const playgroundPeerPath = path.join(playgroundDir, 'node_modules', peerName)
|
|
if (!existsSync(playgroundPeerPath))
|
|
throw new Error(`Missing ${peerName} in ${playgroundDir}`)
|
|
|
|
const stats = lstatSync(packagePeerPath)
|
|
if (!stats.isSymbolicLink())
|
|
throw new Error(`Expected ${packagePeerPath} to be a symlink`)
|
|
|
|
originalLinks.push({
|
|
packagePeerPath,
|
|
originalTarget: readlinkSync(packagePeerPath),
|
|
})
|
|
|
|
rmSync(packagePeerPath, { force: true, recursive: true })
|
|
symlinkSync(playgroundPeerPath, packagePeerPath, 'dir')
|
|
}
|
|
|
|
return () => {
|
|
for (const { packagePeerPath, originalTarget } of originalLinks) {
|
|
rmSync(packagePeerPath, { force: true, recursive: true })
|
|
symlinkSync(originalTarget, packagePeerPath, 'dir')
|
|
}
|
|
}
|
|
}
|
|
|
|
function startServer({ cwd, port, mode }) {
|
|
const logBuffer = []
|
|
const args = mode === 'dev'
|
|
? ['exec', 'next', 'dev', '--hostname', host, '--port', String(port)]
|
|
: ['exec', 'next', 'start', '--hostname', host, '--port', String(port)]
|
|
|
|
const child = spawn('pnpm', args, {
|
|
cwd,
|
|
env: {
|
|
...process.env,
|
|
CI: '1',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
})
|
|
|
|
const appendLogs = (chunk) => {
|
|
logBuffer.push(String(chunk))
|
|
if (logBuffer.length > 200)
|
|
logBuffer.splice(0, logBuffer.length - 200)
|
|
}
|
|
|
|
child.stdout.on('data', appendLogs)
|
|
child.stderr.on('data', appendLogs)
|
|
|
|
return {
|
|
child,
|
|
getLogs() {
|
|
return logBuffer.join('')
|
|
},
|
|
}
|
|
}
|
|
|
|
async function fetchText(url) {
|
|
const response = await fetch(url)
|
|
if (!response.ok)
|
|
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
|
return await response.text()
|
|
}
|
|
|
|
function assertIncludes(html, value, label) {
|
|
if (!html.includes(value))
|
|
throw new Error(`Missing ${label}: ${value}`)
|
|
}
|
|
|
|
function assertRawHtml(html, version, router) {
|
|
assertIncludes(html, `data-ssr-version="${version}"`, 'version marker')
|
|
assertIncludes(html, `data-ssr-router="${router}"`, 'router marker')
|
|
|
|
const requiredPairs = [
|
|
['basic', 'next'],
|
|
['basic', 'server'],
|
|
['custom', 'next'],
|
|
['custom', 'server'],
|
|
['enhanced', 'next'],
|
|
['fallback', 'next'],
|
|
['fallback', 'server'],
|
|
['export-matrix', 'next'],
|
|
['server-export-matrix', 'server'],
|
|
['hydration', 'next'],
|
|
['hydration', 'server'],
|
|
]
|
|
|
|
for (const [caseName, entryName] of requiredPairs) {
|
|
assertIncludes(html, `data-ssr-case="${caseName}"`, `${caseName} case`)
|
|
assertIncludes(html, `data-ssr-entry="${entryName}"`, `${entryName} entry`)
|
|
}
|
|
|
|
assertIncludes(html, 'Next Entry Basic', 'next basic heading')
|
|
assertIncludes(html, 'Server Entry Basic', 'server basic heading')
|
|
assertIncludes(html, 'data-ssr-custom-id="next-primary"', 'next primary custom scope')
|
|
assertIncludes(html, 'data-ssr-custom-id="next-alt"', 'next alternate custom scope')
|
|
assertIncludes(html, 'data-ssr-custom-id="server-primary"', 'server primary custom scope')
|
|
assertIncludes(html, 'data-ssr-custom-id="server-alt"', 'server alternate custom scope')
|
|
assertIncludes(html, 'data-ssr-status="next-paragraph-override"', 'next custom paragraph')
|
|
assertIncludes(html, 'data-ssr-status="next-paragraph-alt"', 'next alternate custom paragraph')
|
|
assertIncludes(html, 'data-ssr-status="server-paragraph-override"', 'server custom paragraph')
|
|
assertIncludes(html, 'data-ssr-status="server-paragraph-alt"', 'server alternate custom paragraph')
|
|
assertIncludes(html, 'data-ssr-status="next-custom-node-children"', 'next custom node children')
|
|
assertIncludes(html, 'data-ssr-status="server-custom-node-children"', 'server custom node children')
|
|
assertIncludes(html, 'Next custom child body', 'next custom child body')
|
|
assertIncludes(html, 'Server custom child body', 'server custom child body')
|
|
assertIncludes(html, 'data-ssr-status="next-thinking-tag"', 'next custom tag')
|
|
assertIncludes(html, 'data-ssr-status="next-thinking-tag-alt"', 'next alternate custom tag')
|
|
assertIncludes(html, 'data-ssr-status="server-thinking-tag"', 'server custom tag')
|
|
assertIncludes(html, 'data-ssr-status="server-thinking-tag-alt"', 'server alternate custom tag')
|
|
assertIncludes(html, 'data-ssr-tone="calm"', 'custom tag attrs')
|
|
assertIncludes(html, 'data-ssr-scope="next-primary"', 'next custom tag scope')
|
|
assertIncludes(html, 'data-ssr-scope="server-primary"', 'server custom tag scope')
|
|
assertIncludes(html, 'data-ssr-fallback="code-block"', 'code fallback')
|
|
assertIncludes(html, 'data-ssr-fallback="markdown-code-block"', 'markdown code fallback')
|
|
assertIncludes(html, 'data-ssr-fallback="mermaid"', 'mermaid fallback')
|
|
assertIncludes(html, 'data-ssr-fallback="d2"', 'd2 fallback')
|
|
assertIncludes(html, 'data-ssr-fallback="infographic"', 'infographic fallback')
|
|
|
|
for (const name of EXPORT_NAMES)
|
|
assertIncludes(html, `data-ssr-export="${name}"`, `${name} export marker`)
|
|
}
|
|
|
|
async function waitForHydration(page, route) {
|
|
await page.goto(route, { waitUntil: 'domcontentloaded' })
|
|
await page.waitForSelector('[data-ssr-case="hydration"][data-ssr-entry="next"][data-ssr-status="enhanced"]', { timeout: 90000 })
|
|
await page.waitForSelector('[data-ssr-case="hydration"][data-ssr-entry="server"][data-ssr-status="fallback-stable"]', { timeout: 90000 })
|
|
}
|
|
|
|
async function assertHydratedUi(page) {
|
|
const nextEnhanced = page.locator('[data-ssr-case="enhanced"][data-ssr-entry="next"]')
|
|
const nextCustomPrimary = page.locator('[data-ssr-case="custom"][data-ssr-entry="next"] [data-ssr-custom-id="next-primary"]')
|
|
const nextCustomAlt = page.locator('[data-ssr-case="custom"][data-ssr-entry="next"] [data-ssr-custom-id="next-alt"]')
|
|
const serverCustomPrimary = page.locator('[data-ssr-case="custom"][data-ssr-entry="server"] [data-ssr-custom-id="server-primary"]')
|
|
const serverCustomAlt = page.locator('[data-ssr-case="custom"][data-ssr-entry="server"] [data-ssr-custom-id="server-alt"]')
|
|
const exportCount = await page.locator('[data-ssr-case="export-matrix"] [data-ssr-export]').count()
|
|
if (exportCount !== EXPORT_NAMES.length)
|
|
throw new Error(`Expected ${EXPORT_NAMES.length} next export markers, received ${exportCount}`)
|
|
|
|
const serverExportCount = await page.locator('[data-ssr-case="server-export-matrix"] [data-ssr-export]').count()
|
|
if (serverExportCount !== EXPORT_NAMES.length)
|
|
throw new Error(`Expected ${EXPORT_NAMES.length} server export markers, received ${serverExportCount}`)
|
|
|
|
const codeEnhanced = await nextEnhanced.locator('[data-ssr-demo="code"] .monaco-editor, [data-ssr-demo="code"] .code-editor-fallback-surface').count()
|
|
if (codeEnhanced === 0)
|
|
throw new Error('Code block did not enhance to the client shell')
|
|
|
|
if (!await nextEnhanced.locator('[data-ssr-demo="math"] .katex').first().isVisible())
|
|
throw new Error('Math block did not enhance to KaTeX')
|
|
|
|
const mermaidShellCount = await nextEnhanced.locator('[data-ssr-demo="mermaid"] .mermaid-block').count()
|
|
const mermaidFallbackCount = await nextEnhanced.locator('[data-ssr-demo="mermaid"] [data-ssr-fallback="mermaid"]').count()
|
|
if (mermaidShellCount === 0 || mermaidFallbackCount > 0)
|
|
throw new Error('Mermaid block did not switch to the client shell')
|
|
|
|
const fallbackCount = await page.locator('[data-ssr-case="fallback"][data-ssr-entry="server"] [data-ssr-fallback]').count()
|
|
if (fallbackCount < 3)
|
|
throw new Error(`Expected stable server fallback markers, received ${fallbackCount}`)
|
|
|
|
if (!await nextCustomPrimary.locator('[data-ssr-status="next-custom-node-children"]').first().isVisible())
|
|
throw new Error('Next custom node children did not render in the primary custom scope')
|
|
|
|
if (!await nextCustomPrimary.locator('[data-ssr-status="next-thinking-tag"][data-ssr-tone="calm"][data-ssr-scope="next-primary"]').first().isVisible())
|
|
throw new Error('Next custom tag attrs did not render in the primary custom scope')
|
|
|
|
if (await nextCustomPrimary.locator('[data-ssr-status="next-paragraph-alt"]').count() > 0)
|
|
throw new Error('Next primary custom scope leaked alternate overrides')
|
|
|
|
if (await nextCustomAlt.locator('[data-ssr-status="next-paragraph-override"]').count() > 0)
|
|
throw new Error('Next alternate custom scope leaked primary overrides')
|
|
|
|
if (!await serverCustomPrimary.locator('[data-ssr-status="server-custom-node-children"]').first().isVisible())
|
|
throw new Error('Server custom node children did not render in the primary custom scope')
|
|
|
|
if (!await serverCustomPrimary.locator('[data-ssr-status="server-thinking-tag"][data-ssr-tone="calm"][data-ssr-scope="server-primary"]').first().isVisible())
|
|
throw new Error('Server custom tag attrs did not render in the primary custom scope')
|
|
|
|
if (await serverCustomPrimary.locator('[data-ssr-status="server-paragraph-alt"]').count() > 0)
|
|
throw new Error('Server primary custom scope leaked alternate overrides')
|
|
|
|
if (await serverCustomAlt.locator('[data-ssr-status="server-paragraph-override"]').count() > 0)
|
|
throw new Error('Server alternate custom scope leaked primary overrides')
|
|
}
|
|
|
|
async function runBrowserChecks(baseUrl) {
|
|
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
|
const page = await browser.newPage()
|
|
const browserErrors = []
|
|
const shouldIgnoreBrowserError = text =>
|
|
text.includes('An empty string ("") was passed to the')
|
|
&& text.includes('attribute')
|
|
&& text.includes('src')
|
|
|
|
page.on('pageerror', (error) => {
|
|
browserErrors.push(`pageerror: ${error.message}`)
|
|
})
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error' && !shouldIgnoreBrowserError(message.text()))
|
|
browserErrors.push(`console: ${message.text()}`)
|
|
})
|
|
|
|
try {
|
|
const appRoute = `${baseUrl}/app-ssr-lab`
|
|
const pagesRoute = `${baseUrl}/pages-ssr-lab`
|
|
|
|
await waitForHydration(page, appRoute)
|
|
await assertHydratedUi(page)
|
|
|
|
await waitForHydration(page, pagesRoute)
|
|
await assertHydratedUi(page)
|
|
|
|
await waitForHydration(page, appRoute)
|
|
await assertHydratedUi(page)
|
|
|
|
await page.reload({ waitUntil: 'domcontentloaded' })
|
|
await page.waitForSelector('[data-ssr-case="hydration"][data-ssr-entry="next"][data-ssr-status="enhanced"]', { timeout: 90000 })
|
|
|
|
if (browserErrors.length > 0)
|
|
throw new Error(browserErrors.join('\n'))
|
|
}
|
|
finally {
|
|
await browser.close()
|
|
}
|
|
}
|
|
|
|
async function runMode({ playgroundDir, version, mode }) {
|
|
const port = await findFreePort(mode === 'dev' ? 3400 : 3500, mode === 'dev' ? 3499 : 3599)
|
|
const baseUrl = `http://${host}:${port}`
|
|
const server = startServer({ cwd: playgroundDir, port, mode })
|
|
|
|
try {
|
|
await waitForPort(port)
|
|
await sleep(mode === 'dev' ? 2500 : 1500)
|
|
|
|
const appHtml = await fetchText(`${baseUrl}/app-ssr-lab`)
|
|
const pagesHtml = await fetchText(`${baseUrl}/pages-ssr-lab`)
|
|
assertRawHtml(appHtml, version, 'app')
|
|
assertRawHtml(pagesHtml, version, 'pages')
|
|
await runBrowserChecks(baseUrl)
|
|
}
|
|
catch (error) {
|
|
throw new Error(`${String(error instanceof Error ? error.message : error)}\n\nServer logs:\n${server.getLogs()}`)
|
|
}
|
|
finally {
|
|
killProcessTree(server.child)
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const { playground, version } = parseArgs(process.argv)
|
|
const playgroundDir = path.join(repoRoot, playground)
|
|
const restorePeers = syncMarkstreamReactPeers(playgroundDir)
|
|
|
|
try {
|
|
console.log(`[next-ssr] preparing ${version} at ${playground}`)
|
|
await runCommand('pnpm', ['run', 'build:parser'], repoRoot)
|
|
await runCommand('pnpm', ['--filter', 'markstream-react', 'build'], repoRoot)
|
|
|
|
console.log(`[next-ssr] ${version} dev`)
|
|
await runMode({ playgroundDir, version, mode: 'dev' })
|
|
|
|
console.log(`[next-ssr] ${version} build`)
|
|
await runCommand('pnpm', ['exec', 'next', 'build'], playgroundDir)
|
|
|
|
console.log(`[next-ssr] ${version} start`)
|
|
await runMode({ playgroundDir, version, mode: 'prod' })
|
|
|
|
console.log(`[next-ssr] ${version} passed`)
|
|
}
|
|
finally {
|
|
restorePeers()
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error)
|
|
process.exitCode = 1
|
|
})
|