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.
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import process from 'node:process'
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
|
||||
function runGit(args, options) {
|
||||
return run('git', args, options)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
legacyPrefix: 'v',
|
||||
remote: 'origin',
|
||||
dryRun: true,
|
||||
push: false,
|
||||
rename: {
|
||||
// Historical package rename: keep tags stable under the current package name.
|
||||
'vue-renderer-markdown': 'markstream-vue',
|
||||
},
|
||||
}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const cur = argv[i]
|
||||
if (cur === '--legacy-prefix') {
|
||||
args.legacyPrefix = argv[++i]
|
||||
}
|
||||
else if (cur === '--remote') {
|
||||
args.remote = argv[++i]
|
||||
}
|
||||
else if (cur === '--apply') {
|
||||
args.dryRun = false
|
||||
}
|
||||
else if (cur === '--dry-run') {
|
||||
args.dryRun = true
|
||||
}
|
||||
else if (cur === '--push') {
|
||||
args.push = true
|
||||
}
|
||||
else if (cur === '--rename') {
|
||||
const raw = String(argv[++i] ?? '')
|
||||
const [from, to] = raw.split('=')
|
||||
if (!from || !to)
|
||||
throw new Error(`Invalid --rename value (expected from=to): ${raw}`)
|
||||
args.rename[from.trim()] = to.trim()
|
||||
}
|
||||
else if (cur === '--help' || cur === '-h') {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/backfill-legacy-tags.mjs [options]
|
||||
|
||||
This script creates namespaced package tags (e.g. stream-markdown-parser@0.0.53)
|
||||
from legacy tags (e.g. v0.0.53) when it can uniquely map a legacy tag to exactly
|
||||
one package's version at that commit.
|
||||
|
||||
Options:
|
||||
--legacy-prefix <p> Legacy tag prefix (default: v)
|
||||
--dry-run Print actions only (default)
|
||||
--apply Actually create tags
|
||||
--push Push created tags to remote
|
||||
--remote <name> Remote name (default: origin)
|
||||
--rename <from=to> Rewrite package name when creating tags (repeatable)
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unknown argument: ${cur}`)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
const RELEASE_PACKAGE_JSON_PATHS = [
|
||||
'package.json',
|
||||
'packages/markdown-parser/package.json',
|
||||
'packages/markstream-react/package.json',
|
||||
'packages/markstream-vue2/package.json',
|
||||
]
|
||||
|
||||
async function getPackageMetaAtTag(legacyTag, packageJsonPath) {
|
||||
try {
|
||||
const raw = runGit(['show', `${legacyTag}:${packageJsonPath}`])
|
||||
const parsed = JSON.parse(raw)
|
||||
const name = String(parsed?.name ?? '').trim()
|
||||
const version = String(parsed?.version ?? '').trim()
|
||||
if (!name || !version)
|
||||
return null
|
||||
return { name, version, packageJsonPath }
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getCommitForTag(tag) {
|
||||
try {
|
||||
return runGit(['rev-parse', `${tag}^{}`]).trim()
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const cwd = process.cwd()
|
||||
|
||||
const legacyPattern = `${args.legacyPrefix}*`
|
||||
const legacyTags = runGit(['tag', '--list', legacyPattern])
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!legacyTags.length) {
|
||||
console.log(`[backfill] No legacy tags found for pattern: ${legacyPattern}`)
|
||||
return
|
||||
}
|
||||
|
||||
const created = []
|
||||
const skipped = []
|
||||
|
||||
for (const legacyTag of legacyTags) {
|
||||
if (!legacyTag.startsWith(args.legacyPrefix)) {
|
||||
skipped.push({ legacyTag, reason: 'prefix-mismatch' })
|
||||
continue
|
||||
}
|
||||
|
||||
const version = legacyTag.slice(args.legacyPrefix.length)
|
||||
if (!version) {
|
||||
skipped.push({ legacyTag, reason: 'empty-version' })
|
||||
continue
|
||||
}
|
||||
|
||||
const matches = []
|
||||
for (const pj of RELEASE_PACKAGE_JSON_PATHS) {
|
||||
const meta = await getPackageMetaAtTag(legacyTag, pj)
|
||||
if (meta && meta.version === version)
|
||||
matches.push(meta)
|
||||
}
|
||||
|
||||
if (matches.length !== 1) {
|
||||
skipped.push({ legacyTag, reason: `non-unique(${matches.length})` })
|
||||
continue
|
||||
}
|
||||
|
||||
const target = matches[0]
|
||||
const canonicalName = args.rename[target.name] ?? target.name
|
||||
const newTag = `${canonicalName}@${target.version}`
|
||||
const existingCommit = getCommitForTag(newTag)
|
||||
if (existingCommit) {
|
||||
skipped.push({ legacyTag, reason: `already-exists(${newTag})` })
|
||||
continue
|
||||
}
|
||||
|
||||
const legacyCommit = getCommitForTag(legacyTag)
|
||||
if (!legacyCommit) {
|
||||
skipped.push({ legacyTag, reason: 'legacy-tag-missing-commit' })
|
||||
continue
|
||||
}
|
||||
|
||||
const tagArgs = ['tag', '-a', newTag, legacyTag, '-m', `Backfill from ${legacyTag}`]
|
||||
if (args.dryRun) {
|
||||
console.log(`[backfill][dry-run] git ${tagArgs.join(' ')}`)
|
||||
}
|
||||
else {
|
||||
runGit(tagArgs, { stdio: 'inherit', cwd })
|
||||
}
|
||||
|
||||
if (args.push) {
|
||||
const pushArgs = ['push', args.remote, newTag]
|
||||
if (args.dryRun) {
|
||||
console.log(`[backfill][dry-run] git ${pushArgs.join(' ')}`)
|
||||
}
|
||||
else {
|
||||
runGit(pushArgs, { stdio: 'inherit', cwd })
|
||||
}
|
||||
}
|
||||
|
||||
created.push({ legacyTag, newTag })
|
||||
}
|
||||
|
||||
console.log(`[backfill] Done. created=${created.length} skipped=${skipped.length}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/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 = 4241, end = 4270) {
|
||||
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, timeoutMs = 60000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
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 startDevServer(port) {
|
||||
const logs = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
child.stdout.on('data', chunk => logs.push(String(chunk)))
|
||||
child.stderr.on('data', chunk => logs.push(String(chunk)))
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logs.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function runScenario(page, codeRendererMode, width) {
|
||||
return page.evaluate(async ({ codeRendererMode, width }) => {
|
||||
const api = window.__heightEstimationExperiment
|
||||
if (!api)
|
||||
throw new Error('Missing __heightEstimationExperiment API.')
|
||||
|
||||
await api.setSourceMode('nodes')
|
||||
await api.setCodeRendererMode(codeRendererMode)
|
||||
await api.setLoadingPhase(false)
|
||||
await api.setPaneWidth(width)
|
||||
await api.waitUntilReady()
|
||||
|
||||
const summary = api.getSummary()
|
||||
const allNodes = await api.runEstimatorBenchmark({
|
||||
width,
|
||||
passes: 5,
|
||||
resetCachesBeforeFirstPass: true,
|
||||
mode: 'all',
|
||||
})
|
||||
|
||||
const skipMeasuredSimpleText = await api.runEstimatorBenchmark({
|
||||
width,
|
||||
passes: 5,
|
||||
resetCachesBeforeFirstPass: true,
|
||||
mode: 'skip-measured-simple-text',
|
||||
})
|
||||
|
||||
return {
|
||||
codeRendererMode,
|
||||
width,
|
||||
summary,
|
||||
benchmarks: {
|
||||
allNodes,
|
||||
skipMeasuredSimpleText,
|
||||
},
|
||||
}
|
||||
}, { codeRendererMode, width })
|
||||
}
|
||||
|
||||
function assertScenario(result) {
|
||||
const id = `${result.codeRendererMode}/${result.width}`
|
||||
const { allNodes, skipMeasuredSimpleText } = result.benchmarks ?? {}
|
||||
if (!(allNodes?.nodeCount >= 1000))
|
||||
throw new Error(`[${id}] Benchmark node count is too low.`)
|
||||
if (!(allNodes?.estimableCount >= 900))
|
||||
throw new Error(`[${id}] Estimable node coverage is too low.`)
|
||||
if (!(Number.isFinite(allNodes?.coldPassMs) && allNodes.coldPassMs > 0))
|
||||
throw new Error(`[${id}] Missing cold benchmark pass.`)
|
||||
if (!(Number.isFinite(allNodes?.warmAverageMs) && allNodes.warmAverageMs > 0))
|
||||
throw new Error(`[${id}] Missing warm benchmark average.`)
|
||||
if (!(Number.isFinite(skipMeasuredSimpleText?.warmAverageMs) && skipMeasuredSimpleText.warmAverageMs > 0))
|
||||
throw new Error(`[${id}] Missing skip-measured-simple-text warm benchmark average.`)
|
||||
if (!(skipMeasuredSimpleText?.skippedMeasuredSimpleTextCount > 0))
|
||||
throw new Error(`[${id}] Expected some measured simple-text nodes to be skipped.`)
|
||||
if (!(skipMeasuredSimpleText?.warmAverageMs < allNodes.warmAverageMs))
|
||||
throw new Error(`[${id}] Skip-measured-simple-text warm pass should be cheaper than all-nodes.`)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
const cleanup = () => killProcessTree(server.child)
|
||||
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: 1600, height: 1200 } })
|
||||
await page.goto(`http://${host}:${port}/height-estimation-experiment`, { waitUntil: 'load' })
|
||||
|
||||
const scenarios = []
|
||||
for (const codeRendererMode of ['markdown', 'monaco']) {
|
||||
for (const width of [520, 1280])
|
||||
scenarios.push(await runScenario(page, codeRendererMode, width))
|
||||
}
|
||||
|
||||
for (const scenario of scenarios)
|
||||
assertScenario(scenario)
|
||||
|
||||
const report = {
|
||||
markdown: {},
|
||||
monaco: {},
|
||||
}
|
||||
|
||||
for (const scenario of scenarios)
|
||||
report[scenario.codeRendererMode][scenario.width] = scenario
|
||||
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
await browser.close()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[benchmark-height-estimation-experiment] failed')
|
||||
console.error(error)
|
||||
console.error(server.getLogs())
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import typescript from '@rollup/plugin-typescript'
|
||||
import { rollup } from 'rollup'
|
||||
|
||||
const root = path.resolve(new URL(import.meta.url).pathname, '..', '..')
|
||||
const outdir = path.resolve(root, 'dist', 'workers')
|
||||
|
||||
async function buildWorker(entry, outName) {
|
||||
const bundle = await rollup({
|
||||
input: entry,
|
||||
external: ['mermaid', 'katex'],
|
||||
plugins: [
|
||||
typescript({ tsconfig: path.resolve(root, 'tsconfig.json'), sourceMap: false }),
|
||||
],
|
||||
})
|
||||
|
||||
await bundle.write({
|
||||
dir: outdir,
|
||||
format: 'es',
|
||||
entryFileNames: outName,
|
||||
chunkFileNames: '[name].js',
|
||||
sourcemap: false,
|
||||
})
|
||||
|
||||
await bundle.close()
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
if (!fs.existsSync(outdir))
|
||||
fs.mkdirSync(outdir, { recursive: true })
|
||||
|
||||
await buildWorker(path.resolve(root, 'src/workers/katexRenderer.worker.ts'), 'katexRenderer.worker.js')
|
||||
await buildWorker(path.resolve(root, 'src/workers/mermaidParser.worker.ts'), 'mermaidParser.worker.js')
|
||||
|
||||
console.log('Workers written to', outdir)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to build workers with rollup', err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const outdir = path.resolve(__dirname, '../dist/workers')
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
await build({
|
||||
entryPoints: [
|
||||
path.resolve(__dirname, '../src/workers/katexRenderer.worker.ts'),
|
||||
path.resolve(__dirname, '../src/workers/mermaidParser.worker.ts'),
|
||||
],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
outdir,
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
external: ['mermaid', 'katex'],
|
||||
target: ['es2017'],
|
||||
})
|
||||
console.log('Workers built to', outdir)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to build workers', err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -0,0 +1,83 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const root = process.cwd()
|
||||
const distDir = join(root, 'dist')
|
||||
|
||||
const budgets = {
|
||||
maxDistBytes: Number(process.env.MAX_DIST_BYTES || 800 * 1024),
|
||||
maxJsChunkBytes: Number(process.env.MAX_JS_CHUNK_BYTES || 300 * 1024),
|
||||
maxPackSizeBytes: Number(process.env.MAX_PACK_TGZ_BYTES || 250 * 1024),
|
||||
maxPackUnpackedBytes: Number(process.env.MAX_PACK_UNPACKED_BYTES || 740 * 1024),
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024)
|
||||
return `${bytes} B`
|
||||
if (bytes < 1024 * 1024)
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
function collectFiles(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const fullPath = join(dir, name)
|
||||
const stat = statSync(fullPath)
|
||||
if (stat.isDirectory()) {
|
||||
collectFiles(fullPath, files)
|
||||
}
|
||||
else {
|
||||
files.push({
|
||||
fullPath,
|
||||
relPath: relative(root, fullPath),
|
||||
size: stat.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const allFiles = collectFiles(distDir)
|
||||
const distBytes = allFiles.reduce((sum, f) => sum + f.size, 0)
|
||||
const jsFiles = allFiles.filter(f => f.relPath.endsWith('.js'))
|
||||
const largestJs = jsFiles.sort((a, b) => b.size - a.size)[0]
|
||||
|
||||
const packJson = execFileSync('npm', ['pack', '--dry-run', '--json'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const pack = JSON.parse(packJson)?.[0]
|
||||
|
||||
if (!pack)
|
||||
throw new Error('Failed to read npm pack --dry-run --json output.')
|
||||
|
||||
const failures = []
|
||||
if (distBytes > budgets.maxDistBytes) {
|
||||
failures.push(`dist total is ${formatBytes(distBytes)}, exceeds budget ${formatBytes(budgets.maxDistBytes)}`)
|
||||
}
|
||||
if (largestJs && largestJs.size > budgets.maxJsChunkBytes) {
|
||||
failures.push(`${largestJs.relPath} is ${formatBytes(largestJs.size)}, exceeds JS chunk budget ${formatBytes(budgets.maxJsChunkBytes)}`)
|
||||
}
|
||||
if (pack.size > budgets.maxPackSizeBytes) {
|
||||
failures.push(`npm tarball is ${formatBytes(pack.size)}, exceeds budget ${formatBytes(budgets.maxPackSizeBytes)}`)
|
||||
}
|
||||
if (pack.unpackedSize > budgets.maxPackUnpackedBytes) {
|
||||
failures.push(`npm unpacked size is ${formatBytes(pack.unpackedSize)}, exceeds budget ${formatBytes(budgets.maxPackUnpackedBytes)}`)
|
||||
}
|
||||
|
||||
console.log(`[size-check] dist total: ${formatBytes(distBytes)}`)
|
||||
if (largestJs)
|
||||
console.log(`[size-check] largest JS: ${largestJs.relPath} (${formatBytes(largestJs.size)})`)
|
||||
console.log(`[size-check] pack tarball: ${formatBytes(pack.size)}`)
|
||||
console.log(`[size-check] pack unpacked: ${formatBytes(pack.unpackedSize)}`)
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[size-check] Budget check failed:')
|
||||
for (const line of failures)
|
||||
console.error(`- ${line}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('[size-check] Budget check passed.')
|
||||
@@ -0,0 +1,56 @@
|
||||
const fs = require('node:fs')
|
||||
const { createRequire } = require('node:module')
|
||||
const path = require('node:path')
|
||||
const process = require('node:process')
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
const packageJsonPaths = [
|
||||
path.join(repoRoot, 'package.json'),
|
||||
...fs.readdirSync(path.join(repoRoot, 'packages'), { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => path.join(repoRoot, 'packages', entry.name, 'package.json'))
|
||||
.filter(file => fs.existsSync(file)),
|
||||
]
|
||||
|
||||
let hasError = false
|
||||
|
||||
for (const packageJsonPath of packageJsonPaths) {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
|
||||
const peerDependencies = pkg.peerDependencies || {}
|
||||
const peerDependenciesMeta = pkg.peerDependenciesMeta || {}
|
||||
const peerNames = Object.keys(peerDependencies)
|
||||
|
||||
if (peerNames.length === 0)
|
||||
continue
|
||||
|
||||
const packageDir = path.dirname(packageJsonPath)
|
||||
const requireFromPackage = createRequire(path.join(packageDir, 'package.json'))
|
||||
|
||||
console.log(`\n${pkg.name}`)
|
||||
|
||||
for (const peerName of peerNames) {
|
||||
const isOptional = !!peerDependenciesMeta[peerName]?.optional
|
||||
|
||||
try {
|
||||
const resolvedPackageJson = requireFromPackage.resolve(`${peerName}/package.json`)
|
||||
const installedPackageJson = JSON.parse(fs.readFileSync(resolvedPackageJson, 'utf8'))
|
||||
console.log(` OK ${peerName}@${installedPackageJson.version}${isOptional ? ' (optional)' : ''}`)
|
||||
}
|
||||
catch {
|
||||
if (isOptional) {
|
||||
console.log(` MISS ${peerName} (optional)`)
|
||||
continue
|
||||
}
|
||||
|
||||
hasError = true
|
||||
console.error(` FAIL ${peerName} is required but not installed`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
console.error('\nPeer dependency check failed.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('\nPeer dependency check passed.')
|
||||
@@ -0,0 +1,37 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const root = process.cwd()
|
||||
const enDir = path.join(root, 'docs', 'guide')
|
||||
const zhDir = path.join(root, 'docs', 'zh', 'guide')
|
||||
|
||||
async function main() {
|
||||
const files = await fs.readdir(enDir)
|
||||
const mdFiles = files.filter(f => f.endsWith('.md'))
|
||||
const missing = []
|
||||
for (const f of mdFiles) {
|
||||
const zhPath = path.join(zhDir, f)
|
||||
try {
|
||||
await fs.access(zhPath)
|
||||
}
|
||||
catch {
|
||||
missing.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length) {
|
||||
console.error('Missing zh translations for the following docs:')
|
||||
missing.forEach(m => console.error(' -', m))
|
||||
console.error('\nHint: run `pnpm docs:sync-zh` to create placeholders.')
|
||||
process.exit(1)
|
||||
}
|
||||
else {
|
||||
console.log('All docs/guide pages have a corresponding docs/zh/guide placeholder.')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const process = require('node:process')
|
||||
|
||||
const distDir = path.resolve(__dirname, '..', 'dist')
|
||||
const typesDir = path.join(distDir, 'types')
|
||||
|
||||
function rewriteTypesEntry(content) {
|
||||
return content
|
||||
.replace(/from\s+(['"])\.\//g, 'from $1./types/')
|
||||
.replace(/import\((['"])\.\//g, 'import($1./types/')
|
||||
}
|
||||
|
||||
function referencesTypesDir(content) {
|
||||
return /from\s+['"]\.\/types\/|import\(['"]\.\/types\//.test(content)
|
||||
}
|
||||
|
||||
const sourcePath = path.join(typesDir, 'exports.d.ts')
|
||||
const targetPath = path.join(distDir, 'index.d.ts')
|
||||
|
||||
if (fs.existsSync(targetPath)) {
|
||||
const bundledContent = fs.readFileSync(targetPath, 'utf8')
|
||||
if (!referencesTypesDir(bundledContent)) {
|
||||
if (fs.existsSync(typesDir))
|
||||
fs.rmSync(typesDir, { recursive: true, force: true })
|
||||
console.log('Keeping bundled', targetPath)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
const content = rewriteTypesEntry(fs.readFileSync(sourcePath, 'utf8'))
|
||||
fs.writeFileSync(targetPath, content)
|
||||
console.log('Rewrote', targetPath, 'from', sourcePath)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const fromDir = path.resolve('dist-tw')
|
||||
const toDir = path.resolve('dist')
|
||||
|
||||
const fromCss = path.join(fromDir, 'index.tailwind.css')
|
||||
const toCss = path.join(toDir, 'index.tailwind.css')
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(toDir, { recursive: true })
|
||||
|
||||
try {
|
||||
await fs.copyFile(fromCss, toCss)
|
||||
}
|
||||
catch (err) {
|
||||
const e = err
|
||||
throw new Error(`[copy-tailwind-css] Missing ${fromCss}. Did the tailwind build run? (${e?.message || e})`)
|
||||
}
|
||||
|
||||
await fs.rm(fromDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,253 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
|
||||
function runGit(args, options) {
|
||||
return run('git', args, options)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
remote: 'origin',
|
||||
push: false,
|
||||
dryRun: false,
|
||||
date: null,
|
||||
all: false,
|
||||
}
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const cur = argv[i]
|
||||
if (cur === '--remote') {
|
||||
args.remote = argv[++i]
|
||||
}
|
||||
else if (cur === '--push') {
|
||||
args.push = true
|
||||
}
|
||||
else if (cur === '--dry-run') {
|
||||
args.dryRun = true
|
||||
}
|
||||
else if (cur === '--date') {
|
||||
args.date = argv[++i]
|
||||
}
|
||||
else if (cur === '--all') {
|
||||
args.all = true
|
||||
}
|
||||
else if (cur === '--help' || cur === '-h') {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/create-nightly-tags.mjs [options]
|
||||
|
||||
Options:
|
||||
--push Push created tags to remote
|
||||
--remote <name> Git remote name (default: origin)
|
||||
--dry-run Print actions only
|
||||
--date <YYYYMMDD> Override date segment (default: UTC today)
|
||||
--all Tag all packages even if unchanged
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unknown argument: ${cur}`)
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function utcDateYYYYMMDD() {
|
||||
const d = new Date()
|
||||
const y = String(d.getUTCFullYear())
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getUTCDate()).padStart(2, '0')
|
||||
return `${y}${m}${day}`
|
||||
}
|
||||
|
||||
function readJson(relativePath) {
|
||||
const abs = path.resolve(process.cwd(), relativePath)
|
||||
return JSON.parse(readFileSync(abs, 'utf8'))
|
||||
}
|
||||
|
||||
function resolvePackage(relativePackageJson, paths) {
|
||||
const pkg = readJson(relativePackageJson)
|
||||
const name = String(pkg?.name ?? '').trim()
|
||||
if (!name)
|
||||
throw new Error(`Invalid package.json (missing name): ${relativePackageJson}`)
|
||||
return { name, packageJson: relativePackageJson, paths }
|
||||
}
|
||||
|
||||
function listTags(glob) {
|
||||
return runGit(['tag', '--list', glob])
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function latestTag(glob) {
|
||||
const tags = listTags(glob)
|
||||
if (!tags.length)
|
||||
return null
|
||||
// Sort by taggerdate / creatordate (annotated tags); fallback by refname.
|
||||
const sorted = runGit([
|
||||
'for-each-ref',
|
||||
'--sort=-creatordate',
|
||||
'--format=%(refname:strip=2)',
|
||||
'refs/tags',
|
||||
])
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const set = new Set(tags)
|
||||
for (const t of sorted) {
|
||||
if (set.has(t))
|
||||
return t
|
||||
}
|
||||
return tags[tags.length - 1]
|
||||
}
|
||||
|
||||
function shortSha(ref = 'HEAD') {
|
||||
return runGit(['rev-parse', '--short=7', ref]).trim()
|
||||
}
|
||||
|
||||
function hasChangesSince(baseRef, paths) {
|
||||
if (!baseRef)
|
||||
return true
|
||||
const baseSha = runGit(['rev-parse', `${baseRef}^{}`]).trim()
|
||||
const headSha = runGit(['rev-parse', 'HEAD^{}']).trim()
|
||||
if (baseSha === headSha)
|
||||
return false
|
||||
const files = runGit(['diff', '--name-only', `${baseRef}..HEAD`, '--', ...paths]).trim()
|
||||
return Boolean(files)
|
||||
}
|
||||
|
||||
function tagExists(tag) {
|
||||
try {
|
||||
runGit(['rev-parse', `${tag}^{}`])
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createAnnotatedTag(tag, message) {
|
||||
const args = ['tag', '-a', tag, '-m', message]
|
||||
return ['git', ...args]
|
||||
}
|
||||
|
||||
function pushTag(remote, tag) {
|
||||
return ['git', 'push', remote, tag]
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const date = args.date ?? utcDateYYYYMMDD()
|
||||
const sha = shortSha('HEAD')
|
||||
|
||||
const packages = [
|
||||
resolvePackage('package.json', [
|
||||
'package.json',
|
||||
'src',
|
||||
'scripts',
|
||||
]),
|
||||
resolvePackage('packages/markstream-vue2/package.json', [
|
||||
'packages/markstream-vue2',
|
||||
]),
|
||||
resolvePackage('packages/markstream-react/package.json', [
|
||||
'packages/markstream-react',
|
||||
]),
|
||||
resolvePackage('packages/markdown-parser/package.json', [
|
||||
'packages/markdown-parser',
|
||||
]),
|
||||
]
|
||||
|
||||
const packageByName = new Map(packages.map(p => [p.name, p]))
|
||||
const parserName = 'stream-markdown-parser'
|
||||
const parserPropagatesTo = new Set([
|
||||
'markstream-vue',
|
||||
'markstream-vue2',
|
||||
'markstream-react',
|
||||
])
|
||||
if (!packageByName.has(parserName)) {
|
||||
throw new Error(`[nightly] Expected parser package "${parserName}" not found in package list.`)
|
||||
}
|
||||
|
||||
const created = []
|
||||
|
||||
const directChangedByName = new Map()
|
||||
const baseRefByName = new Map()
|
||||
for (const pkg of packages) {
|
||||
const baseNightly = latestTag(`${pkg.name}@nightly-*`)
|
||||
const baseStable = latestTag(`${pkg.name}@[0-9]*`)
|
||||
const base = baseNightly ?? baseStable
|
||||
baseRefByName.set(pkg.name, base)
|
||||
const changed = args.all ? true : hasChangesSince(base, pkg.paths)
|
||||
directChangedByName.set(pkg.name, changed)
|
||||
}
|
||||
|
||||
const parserChanged = Boolean(directChangedByName.get(parserName))
|
||||
const effectiveChangedByName = new Map(directChangedByName)
|
||||
const nightlyReasonByName = new Map()
|
||||
for (const [name, changed] of directChangedByName.entries()) {
|
||||
if (changed) {
|
||||
nightlyReasonByName.set(name, 'direct')
|
||||
continue
|
||||
}
|
||||
if (parserChanged && parserPropagatesTo.has(name)) {
|
||||
effectiveChangedByName.set(name, true)
|
||||
nightlyReasonByName.set(name, `dependency:${parserName}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkg of packages) {
|
||||
const changed = Boolean(effectiveChangedByName.get(pkg.name))
|
||||
if (!changed)
|
||||
continue
|
||||
|
||||
const tag = `${pkg.name}@nightly-${date}-${sha}`
|
||||
if (tagExists(tag))
|
||||
continue
|
||||
|
||||
const reason = nightlyReasonByName.get(pkg.name) ?? 'direct'
|
||||
const message = reason === 'direct'
|
||||
? `${pkg.name} nightly ${date} ${sha}`
|
||||
: `${pkg.name} nightly ${date} ${sha} (${reason})`
|
||||
const cmd = createAnnotatedTag(tag, message)
|
||||
if (args.dryRun) {
|
||||
console.log(`[nightly][dry-run] ${cmd.join(' ')}`)
|
||||
}
|
||||
else {
|
||||
runGit(cmd.slice(1), { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
if (args.push) {
|
||||
const pushCmd = pushTag(args.remote, tag)
|
||||
if (args.dryRun) {
|
||||
console.log(`[nightly][dry-run] ${pushCmd.join(' ')}`)
|
||||
}
|
||||
else {
|
||||
runGit(pushCmd.slice(1), { stdio: 'inherit' })
|
||||
}
|
||||
}
|
||||
|
||||
created.push(tag)
|
||||
}
|
||||
|
||||
// Machine-readable output for workflows.
|
||||
|
||||
console.log(JSON.stringify({ created }, null, 2))
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getMarkdown } from '../src/utils/markdown/getMarkdown.js'
|
||||
|
||||
const md = getMarkdown({})
|
||||
const content = '**Figure: Vue Markdown Icon (served from /vue-markdown-icon.svg)**'
|
||||
const tokens = md.parse(content, {})
|
||||
console.log('parsed tokens count', tokens.length)
|
||||
if (tokens[1] && tokens[1].children) {
|
||||
console.log('inline children length', tokens[1].children.length)
|
||||
console.log(JSON.stringify(tokens[1].children, null, 2))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getMarkdown } from '../src/utils/markdown/getMarkdown'
|
||||
|
||||
const md = getMarkdown({})
|
||||
|
||||
// The content above is intentionally escaped wrong to mimic test; instead
|
||||
// use the original test string:
|
||||
const test = '可以看作 \\(' + '\\boldsymbol{\\beta}' + '\\) 与 \\(' + 'W' + '\\) 正交的一个特例(当 \\(' + 'W' + '\\) 只由 \\(' + '\\boldsymbol{\\alpha}' + '\\) 张成时)。'
|
||||
|
||||
console.log('INPUT:', test)
|
||||
const tokens = md.parse(test, {})
|
||||
console.log('TOKENS:')
|
||||
for (const t of tokens) {
|
||||
console.log('type:', t.type, 'tag:', t.tag, 'content:', t.content ? t.content : '')
|
||||
if (t.children) {
|
||||
for (const c of t.children) {
|
||||
console.log(' child:', c.type, c.tag, c.content ? c.content : '')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
#!/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-angular')
|
||||
const host = '127.0.0.1'
|
||||
|
||||
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 = 4175, end = 4205) {
|
||||
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 = 60000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < 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',
|
||||
'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 startDevServer(port) {
|
||||
const logBuffer = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['exec', 'vite', 'dev', '--host', host, '--port', String(port)],
|
||||
{
|
||||
cwd: playgroundDir,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
|
||||
const appendLogs = (chunk) => {
|
||||
const text = String(chunk)
|
||||
logBuffer.push(text)
|
||||
if (logBuffer.length > 160)
|
||||
logBuffer.splice(0, logBuffer.length - 160)
|
||||
}
|
||||
|
||||
child.stdout.on('data', appendLogs)
|
||||
child.stderr.on('data', appendLogs)
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logBuffer.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeErrors(errors) {
|
||||
return errors
|
||||
.map(error => error.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isExecutionContextDestroyed(error) {
|
||||
const message = String(error?.message ?? error ?? '')
|
||||
return message.includes('Execution context was destroyed')
|
||||
|| message.includes('most likely because of a navigation')
|
||||
}
|
||||
|
||||
async function installHoverProbe(page) {
|
||||
await page.addInitScript(() => {
|
||||
const globalKey = '__MARKSTREAM_HOVER_PROBE__'
|
||||
if (globalThis[globalKey])
|
||||
return
|
||||
|
||||
const state = {
|
||||
mouseoverCount: 0,
|
||||
mouseoutCount: 0,
|
||||
lastTitle: '',
|
||||
frameCount: 0,
|
||||
maxFrameGap: 0,
|
||||
maxTimerDrift: 0,
|
||||
}
|
||||
|
||||
let lastFrameAt = performance.now()
|
||||
const tick = (now) => {
|
||||
state.frameCount += 1
|
||||
state.maxFrameGap = Math.max(state.maxFrameGap, now - lastFrameAt)
|
||||
lastFrameAt = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
let expectedTimerAt = performance.now() + 100
|
||||
setInterval(() => {
|
||||
const now = performance.now()
|
||||
state.maxTimerDrift = Math.max(state.maxTimerDrift, Math.max(0, now - expectedTimerAt))
|
||||
expectedTimerAt = now + 100
|
||||
}, 100)
|
||||
|
||||
const resolveTarget = (value) => {
|
||||
if (!(value instanceof Element))
|
||||
return null
|
||||
return value.closest('[title], a[href]')
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', (event) => {
|
||||
const target = resolveTarget(event.target)
|
||||
if (!target)
|
||||
return
|
||||
state.mouseoverCount += 1
|
||||
state.lastTitle = target.getAttribute('title') || target.getAttribute('href') || target.textContent || ''
|
||||
}, true)
|
||||
|
||||
document.addEventListener('mouseout', (event) => {
|
||||
const target = resolveTarget(event.target)
|
||||
if (!target)
|
||||
return
|
||||
state.mouseoutCount += 1
|
||||
}, true)
|
||||
|
||||
globalThis[globalKey] = state
|
||||
})
|
||||
}
|
||||
|
||||
async function readHoverProbe(page) {
|
||||
return page.evaluate(() => {
|
||||
const state = globalThis.__MARKSTREAM_HOVER_PROBE__ || {}
|
||||
return {
|
||||
mouseoverCount: Number(state.mouseoverCount || 0),
|
||||
mouseoutCount: Number(state.mouseoutCount || 0),
|
||||
lastTitle: String(state.lastTitle || ''),
|
||||
frameCount: Number(state.frameCount || 0),
|
||||
maxFrameGap: Number(state.maxFrameGap || 0),
|
||||
maxTimerDrift: Number(state.maxTimerDrift || 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function readHomeProgress(page) {
|
||||
return page.evaluate(() => {
|
||||
const text = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
const match = text.match(/^(\d+)\s*\/\s*(\d+)\s*\((\d+)%\)$/)
|
||||
const current = match ? Number.parseInt(match[1], 10) : 0
|
||||
const total = match ? Number.parseInt(match[2], 10) : 0
|
||||
const percent = match ? Number.parseInt(match[3], 10) : 0
|
||||
return {
|
||||
text,
|
||||
current,
|
||||
total,
|
||||
percent,
|
||||
done: total > 0 && current >= total && percent === 100,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function readHomePlaceholderCount(page) {
|
||||
return page.evaluate(() => {
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0
|
||||
return Array.from(document.querySelectorAll('.panel.preview .markstream-angular .node-placeholder'))
|
||||
.filter((node) => {
|
||||
const rect = node.getBoundingClientRect()
|
||||
return rect.width > 0
|
||||
&& rect.height > 0
|
||||
&& rect.bottom > 0
|
||||
&& rect.right > 0
|
||||
&& rect.top < viewportHeight
|
||||
&& rect.left < viewportWidth
|
||||
})
|
||||
.length
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForProbeIncrement(page, key, previousValue, timeout = 1500) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
const current = await readHoverProbe(page)
|
||||
if (current[key] > previousValue)
|
||||
return Date.now() - startedAt
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
throw new Error(`Timed out waiting for hover probe increment: ${key}`)
|
||||
}
|
||||
|
||||
async function waitForHomeHoverTarget(page, timeout = 90000) {
|
||||
const selector = '.panel.preview .markstream-angular [title], .panel.preview .markstream-angular a[href]'
|
||||
const startedAt = Date.now()
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
const locator = page.locator(selector).first()
|
||||
if (await locator.count()) {
|
||||
try {
|
||||
if (await locator.isVisible())
|
||||
return selector
|
||||
}
|
||||
catch {
|
||||
// The preview re-renders while streaming; retry with a fresh locator.
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for a hoverable home preview target.')
|
||||
}
|
||||
|
||||
async function exerciseHomeHoverDuringStreaming(page, selector) {
|
||||
const samples = []
|
||||
const startedAt = Date.now()
|
||||
let maxPlaceholderCount = 0
|
||||
|
||||
while (Date.now() - startedAt < 180000) {
|
||||
const progress = await readHomeProgress(page)
|
||||
if (progress.done)
|
||||
break
|
||||
|
||||
const before = await readHoverProbe(page)
|
||||
const target = page.locator(selector).first()
|
||||
await target.hover({ timeout: 3000 })
|
||||
const overLatencyMs = await waitForProbeIncrement(page, 'mouseoverCount', before.mouseoverCount, 1500)
|
||||
|
||||
await page.mouse.move(8, 8)
|
||||
const outLatencyMs = await waitForProbeIncrement(page, 'mouseoutCount', before.mouseoutCount, 1500)
|
||||
const placeholderCount = await readHomePlaceholderCount(page)
|
||||
maxPlaceholderCount = Math.max(maxPlaceholderCount, placeholderCount)
|
||||
|
||||
samples.push({
|
||||
current: progress.current,
|
||||
percent: progress.percent,
|
||||
overLatencyMs,
|
||||
outLatencyMs,
|
||||
placeholderCount,
|
||||
})
|
||||
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
|
||||
const finalProbe = await readHoverProbe(page)
|
||||
return {
|
||||
interactionCount: samples.length,
|
||||
maxOverLatencyMs: Math.max(0, ...samples.map(sample => sample.overLatencyMs)),
|
||||
maxOutLatencyMs: Math.max(0, ...samples.map(sample => sample.outLatencyMs)),
|
||||
maxPlaceholderCount,
|
||||
finalProbe,
|
||||
samples,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHomeCompletion(page, timeout = 180000) {
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
const match = text.match(/^(\d+)\s*\/\s*(\d+)\s*\((\d+)%\)$/)
|
||||
if (!match)
|
||||
return false
|
||||
const current = Number.parseInt(match[1], 10)
|
||||
const total = Number.parseInt(match[2], 10)
|
||||
const percent = Number.parseInt(match[3], 10)
|
||||
return total > 0 && current >= total && percent === 100
|
||||
}, { timeout })
|
||||
}
|
||||
|
||||
async function collectHomeCompletionMetrics(page) {
|
||||
return page.evaluate(() => {
|
||||
const preview = document.querySelector('.panel.preview .markstream-angular')
|
||||
const text = preview?.textContent || ''
|
||||
const metaText = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
return {
|
||||
metaText,
|
||||
textLength: text.length,
|
||||
containsTaylor: text.includes('泰勒公式'),
|
||||
containsOrthogonalComplement: text.includes('正交补空间'),
|
||||
containsHelloWorldTail: text.includes('hello world'),
|
||||
linkCount: document.querySelectorAll('.panel.preview .markstream-angular a').length,
|
||||
katexCount: document.querySelectorAll('.panel.preview .markstream-angular .katex').length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHomeStreamingCodeHighlight(page, timeout = 180000) {
|
||||
const startedAt = Date.now()
|
||||
let lastSnapshot = null
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
lastSnapshot = await page.evaluate(() => {
|
||||
const text = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
const match = text.match(/^(\d+)\s*\/\s*(\d+)\s*\((\d+)%\)$/)
|
||||
const current = match ? Number.parseInt(match[1], 10) : 0
|
||||
const total = match ? Number.parseInt(match[2], 10) : 0
|
||||
const percent = match ? Number.parseInt(match[3], 10) : 0
|
||||
return {
|
||||
metaText: text,
|
||||
current,
|
||||
total,
|
||||
percent,
|
||||
done: total > 0 && current >= total && percent === 100,
|
||||
monacoCount: document.querySelectorAll('.panel.preview .markstream-angular [data-markstream-monaco="1"]').length,
|
||||
fallbackPreCount: document.querySelectorAll('.panel.preview .markstream-angular .code-fallback-plain').length,
|
||||
}
|
||||
})
|
||||
|
||||
if (lastSnapshot.monacoCount > 0)
|
||||
return lastSnapshot
|
||||
|
||||
if (lastSnapshot.done)
|
||||
return lastSnapshot
|
||||
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for a highlighted home code block: ${JSON.stringify(lastSnapshot)}`)
|
||||
}
|
||||
|
||||
async function collectHomeThinkingMetrics(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const text = document.querySelector('.meta')?.textContent?.trim() || ''
|
||||
const match = text.match(/(\d+)\s*\/\s*(\d+)/)
|
||||
return !!match && Number.parseInt(match[1], 10) >= 1000
|
||||
}, { timeout: 30000 })
|
||||
|
||||
return page.evaluate(() => {
|
||||
const content = document.querySelector('.thinking-node__content')
|
||||
const text = content?.textContent || ''
|
||||
return {
|
||||
textLength: text.trim().length,
|
||||
containsComprehensiveAnswer: text.includes('全面的回答'),
|
||||
paragraphCount: content?.querySelectorAll('p').length || 0,
|
||||
listCount: content?.querySelectorAll('li').length || 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function collectHomeLinkTooltip(page) {
|
||||
const link = page
|
||||
.locator('.panel.preview .markstream-angular a.link-node[href="https://github.com/Simon-He95/markstream-vue"]')
|
||||
.filter({ hasText: 'Star on GitHub' })
|
||||
.first()
|
||||
|
||||
await link.waitFor({ state: 'visible', timeout: 30000 })
|
||||
await link.hover()
|
||||
await page.waitForFunction(() => {
|
||||
const tooltip = document.querySelector('.ms-tooltip')
|
||||
if (!tooltip)
|
||||
return false
|
||||
const style = window.getComputedStyle(tooltip)
|
||||
const text = tooltip.textContent?.trim() || ''
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity || '1') > 0.05
|
||||
&& text.includes('https://github.com/Simon-He95/markstream-vue')
|
||||
}, { timeout: 10000 })
|
||||
|
||||
return page.evaluate(() => {
|
||||
const tooltip = document.querySelector('.ms-tooltip')
|
||||
const style = tooltip ? window.getComputedStyle(tooltip) : null
|
||||
return {
|
||||
text: tooltip?.textContent?.trim() || '',
|
||||
visible: Boolean(
|
||||
tooltip
|
||||
&& style
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity || '1') > 0.05,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForStableEnhancements(page) {
|
||||
await page.waitForSelector('.preview-surface .katex', { timeout: 30000 })
|
||||
await page.waitForSelector('.preview-surface .markstream-angular-mermaid svg', { timeout: 30000 })
|
||||
await page.waitForSelector('.preview-surface [data-markstream-infographic="1"] svg', { timeout: 30000 })
|
||||
await page.waitForSelector('.preview-surface [data-markstream-d2="1"] svg', { timeout: 30000 })
|
||||
}
|
||||
|
||||
async function collectBaselineMetrics(page) {
|
||||
return page.evaluate(() => ({
|
||||
katexCount: document.querySelectorAll('.preview-surface .katex').length,
|
||||
mermaidSvgCount: document.querySelectorAll('.preview-surface .markstream-angular-mermaid svg').length,
|
||||
infographicSvgCount: document.querySelectorAll('.preview-surface [data-markstream-infographic="1"] svg').length,
|
||||
d2SvgCount: document.querySelectorAll('.preview-surface [data-markstream-d2="1"] svg').length,
|
||||
}))
|
||||
}
|
||||
|
||||
async function verifyBaselineInteractions(page) {
|
||||
const mermaidBlock = page.locator('.preview-surface .mermaid-block').first()
|
||||
await mermaidBlock.getByRole('button', { name: 'Source' }).click()
|
||||
await page.waitForSelector('.preview-surface .mermaid-block .mermaid-source', { timeout: 10000 })
|
||||
await mermaidBlock.getByRole('button', { name: 'Preview' }).click()
|
||||
await page.waitForSelector('.preview-surface .mermaid-block .markstream-angular-mermaid svg', { timeout: 10000 })
|
||||
await mermaidBlock.getByRole('button', { name: 'Fullscreen' }).click()
|
||||
await page.waitForSelector('.mermaid-modal-overlay', { timeout: 10000 })
|
||||
await page.getByRole('button', { name: 'Close' }).click()
|
||||
await page.waitForSelector('.mermaid-modal-overlay', { state: 'hidden', timeout: 10000 })
|
||||
|
||||
const d2Block = page.locator('.preview-surface [data-markstream-d2="1"]').first()
|
||||
await d2Block.getByRole('button', { name: 'Source' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const block = document.querySelector('.preview-surface [data-markstream-d2="1"]')
|
||||
return block?.getAttribute('data-mode') === 'source'
|
||||
}, { timeout: 10000 })
|
||||
await d2Block.getByRole('button', { name: 'Preview' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const block = document.querySelector('.preview-surface [data-markstream-d2="1"]')
|
||||
return block?.getAttribute('data-mode') === 'preview'
|
||||
}, { timeout: 10000 })
|
||||
|
||||
const infographicBlock = page.locator('.preview-surface [data-markstream-infographic="1"]').first()
|
||||
await infographicBlock.getByRole('button', { name: 'Source' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const block = document.querySelector('.preview-surface [data-markstream-infographic="1"]')
|
||||
return block?.getAttribute('data-mode') === 'source'
|
||||
}, { timeout: 10000 })
|
||||
await infographicBlock.getByRole('button', { name: 'Preview' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const block = document.querySelector('.preview-surface [data-markstream-infographic="1"]')
|
||||
return block?.getAttribute('data-mode') === 'preview'
|
||||
}, { timeout: 10000 })
|
||||
}
|
||||
|
||||
async function collectDiffMetrics(page) {
|
||||
return page.evaluate(() => ({
|
||||
wrapperCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"]').length,
|
||||
monacoCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"] .monaco-editor').length,
|
||||
monacoDiffCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"] .monaco-diff-editor').length,
|
||||
badgeTexts: Array.from(document.querySelectorAll('.preview-surface .markstream-angular-enhanced-block__badge'))
|
||||
.map(node => node.textContent?.trim())
|
||||
.filter(Boolean),
|
||||
}))
|
||||
}
|
||||
|
||||
async function verifyDiffInteractions(page) {
|
||||
const diffBlock = page.locator('.preview-surface [data-markstream-monaco-diff="1"]').first()
|
||||
await diffBlock.locator('button[title="Collapse"]').first().click()
|
||||
await page.waitForFunction(() => {
|
||||
const body = document.querySelector('.preview-surface [data-markstream-monaco-diff="1"] .code-block-body')
|
||||
return body?.classList.contains('code-block-body--collapsed') === true
|
||||
}, { timeout: 10000 })
|
||||
await diffBlock.locator('button[title="Expand"]').first().click()
|
||||
await page.waitForFunction(() => {
|
||||
const body = document.querySelector('.preview-surface [data-markstream-monaco-diff="1"] .code-block-body')
|
||||
return body?.classList.contains('code-block-body--collapsed') === false
|
||||
}, { timeout: 10000 })
|
||||
await diffBlock.locator('button[title="Copy"]').first().click()
|
||||
await page.waitForFunction(() => {
|
||||
const live = document.querySelector('.preview-surface [data-markstream-monaco-diff="1"] [role="status"]')
|
||||
return live?.textContent?.includes('Copied')
|
||||
}, { timeout: 10000 })
|
||||
}
|
||||
|
||||
async function collectThinkingMetrics(page) {
|
||||
return page.evaluate(() => ({
|
||||
thinkingCount: document.querySelectorAll('.preview-surface .thinking-node').length,
|
||||
mermaidSvgCount: document.querySelectorAll('.preview-surface .thinking-node .markstream-angular-mermaid svg').length,
|
||||
monacoCount: document.querySelectorAll('.preview-surface .thinking-node [data-markstream-monaco="1"]').length,
|
||||
listCount: document.querySelectorAll('.preview-surface .thinking-node li').length,
|
||||
previewExcerpt: (document.querySelector('.preview-surface')?.textContent || '').slice(0, 500),
|
||||
}))
|
||||
}
|
||||
|
||||
async function collectThinkingDebug(page) {
|
||||
return page.evaluate(() => ({
|
||||
thinkingHtml: (document.querySelector('.preview-surface .thinking-node')?.innerHTML || '').slice(0, 8000),
|
||||
blockCount: document.querySelectorAll('.preview-surface .thinking-node .mermaid-block').length,
|
||||
blockHtml: (document.querySelector('.preview-surface .thinking-node .mermaid-block')?.outerHTML || '').slice(0, 4000),
|
||||
sourceCount: document.querySelectorAll('.preview-surface .thinking-node .mermaid-source').length,
|
||||
previewCount: document.querySelectorAll('.preview-surface .thinking-node .mermaid-preview').length,
|
||||
hostCount: document.querySelectorAll('.preview-surface .thinking-node .markstream-angular-mermaid').length,
|
||||
stage: document.querySelector('.preview-surface .thinking-node .mermaid-block')?.getAttribute('data-stage'),
|
||||
errorTexts: Array.from(document.querySelectorAll('.preview-surface .thinking-node .mermaid-error'))
|
||||
.map(node => node.textContent?.trim())
|
||||
.filter(Boolean),
|
||||
}))
|
||||
}
|
||||
|
||||
async function waitForDiffEditors(page, timeout = 60000) {
|
||||
const startedAt = Date.now()
|
||||
let lastSnapshot = null
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
lastSnapshot = await page.evaluate(() => ({
|
||||
wrapperCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"]').length,
|
||||
monacoCount: document.querySelectorAll('.preview-surface [data-markstream-monaco="1"] .monaco-editor').length,
|
||||
badgeTexts: Array.from(document.querySelectorAll('.preview-surface .markstream-angular-enhanced-block__badge'))
|
||||
.map(node => node.textContent?.trim())
|
||||
.filter(Boolean),
|
||||
previewExcerpt: (document.querySelector('.preview-surface')?.textContent || '').slice(0, 500),
|
||||
textareaExcerpt: (((document.querySelector('textarea') && 'value' in document.querySelector('textarea'))
|
||||
? document.querySelector('textarea').value
|
||||
: '') || '').slice(0, 160),
|
||||
}))
|
||||
|
||||
if (lastSnapshot.wrapperCount >= 2 && lastSnapshot.monacoCount >= 2)
|
||||
return lastSnapshot
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for diff Monaco blocks: ${JSON.stringify(lastSnapshot)}`)
|
||||
}
|
||||
|
||||
async function collectStressMetrics(page) {
|
||||
return page.evaluate(() => ({
|
||||
tableCount: document.querySelectorAll('.preview-surface table').length,
|
||||
detailsCount: document.querySelectorAll('.preview-surface details').length,
|
||||
summaryCount: document.querySelectorAll('.preview-surface summary').length,
|
||||
blockquoteCount: document.querySelectorAll('.preview-surface blockquote').length,
|
||||
previewExcerpt: (document.querySelector('.preview-surface')?.textContent || '').slice(0, 500),
|
||||
}))
|
||||
}
|
||||
|
||||
async function collectStreamingMetrics(page) {
|
||||
const progressNode = page.locator('.hero-panel__metrics .metric-card').nth(3).locator('strong')
|
||||
const readProgress = async () => {
|
||||
const text = (await progressNode.textContent()).trim()
|
||||
return Number.parseInt(text.replace(/\D/g, ''), 10)
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: '开始流式渲染' }).click()
|
||||
await page.waitForFunction(() => {
|
||||
const pill = document.querySelectorAll('.workspace-card .mini-pill')[1]
|
||||
const foot = document.querySelectorAll('.workspace-card__foot span')[3]
|
||||
return pill?.textContent?.trim() === 'Streaming'
|
||||
&& foot?.textContent?.includes('正在逐步追加中')
|
||||
}, { timeout: 10000 })
|
||||
|
||||
const earlyProgress = await readProgress()
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const progress = document.querySelectorAll('.hero-panel__metrics .metric-card strong')[3]
|
||||
const pill = document.querySelectorAll('.workspace-card .mini-pill')[1]
|
||||
const foot = document.querySelectorAll('.workspace-card__foot span')[3]
|
||||
return progress?.textContent?.trim() === '100%'
|
||||
&& pill?.textContent?.trim() === 'Ready'
|
||||
&& foot?.textContent?.includes('已显示完整输入')
|
||||
}, { timeout: 30000 })
|
||||
|
||||
return {
|
||||
earlyProgress,
|
||||
finalProgress: await readProgress(),
|
||||
}
|
||||
}
|
||||
|
||||
async function collectHomeStreamingHealth(page, initialMetaText) {
|
||||
let previousMetaText = initialMetaText
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await page.waitForFunction((previous) => {
|
||||
const meta = document.querySelector('.meta')
|
||||
return !!meta && meta.textContent?.trim() !== previous
|
||||
}, previousMetaText, { timeout: 20000 })
|
||||
|
||||
return await page.evaluate(() => new Promise((resolve) => {
|
||||
let frameCount = 0
|
||||
const startedAt = performance.now()
|
||||
const tick = (now) => {
|
||||
frameCount += 1
|
||||
if (now - startedAt >= 800) {
|
||||
resolve({
|
||||
frameCount,
|
||||
metaText: document.querySelector('.meta')?.textContent?.trim() || '',
|
||||
})
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}))
|
||||
}
|
||||
catch (error) {
|
||||
if (!isExecutionContextDestroyed(error) || attempt === 2)
|
||||
throw error
|
||||
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {})
|
||||
await page.waitForSelector('.meta', { timeout: 30000 })
|
||||
previousMetaText = (await page.locator('.meta').textContent().catch(() => initialMetaText)).trim()
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unable to collect home streaming health after navigation retries')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
|
||||
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1600 } })
|
||||
await installHoverProbe(page)
|
||||
const consoleErrors = []
|
||||
const pageErrors = []
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error')
|
||||
consoleErrors.push(msg.text())
|
||||
})
|
||||
page.on('pageerror', error => pageErrors.push(String(error)))
|
||||
|
||||
const homeUrl = `http://${host}:${port}/`
|
||||
await page.goto(homeUrl, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('text=markstream-angular playground', { timeout: 30000 })
|
||||
await page.waitForSelector('text=Open /test', { timeout: 30000 })
|
||||
|
||||
const homeProgressText = (await page.locator('.meta').textContent()).trim()
|
||||
const homeStreamingHealth = await collectHomeStreamingHealth(page, homeProgressText)
|
||||
const homeHoverSelector = await waitForHomeHoverTarget(page)
|
||||
const homeStreamingCodeHighlight = await waitForHomeStreamingCodeHighlight(page)
|
||||
const homeHoverDuringStreaming = await exerciseHomeHoverDuringStreaming(page, homeHoverSelector)
|
||||
const homeThinking = await collectHomeThinkingMetrics(page)
|
||||
const homeLinkTooltip = await collectHomeLinkTooltip(page)
|
||||
await waitForHomeCompletion(page)
|
||||
const homeCompletion = await collectHomeCompletionMetrics(page)
|
||||
await page.getByRole('button', { name: 'Open /test' }).click()
|
||||
await page.waitForURL(new RegExp(`http://${host}:${port}/test$`), { timeout: 15000 })
|
||||
await page.waitForSelector('text=markstream-angular /test', { timeout: 30000 })
|
||||
|
||||
await waitForStableEnhancements(page)
|
||||
const baseline = await collectBaselineMetrics(page)
|
||||
await verifyBaselineInteractions(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Thinking 嵌套重节点' }).click()
|
||||
await page.waitForSelector('.preview-surface .thinking-node', { timeout: 20000 })
|
||||
try {
|
||||
await page.waitForSelector('.preview-surface .thinking-node .markstream-angular-mermaid svg', { timeout: 30000 })
|
||||
}
|
||||
catch (error) {
|
||||
const thinkingDebug = await collectThinkingDebug(page)
|
||||
throw new Error(`Timed out waiting for thinking Mermaid render: ${JSON.stringify(thinkingDebug)}`, { cause: error })
|
||||
}
|
||||
await page.waitForSelector('.preview-surface .thinking-node [data-markstream-monaco="1"]', { timeout: 30000 })
|
||||
const thinking = await collectThinkingMetrics(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Diff 与代码流' }).click()
|
||||
await waitForDiffEditors(page)
|
||||
const diff = await collectDiffMetrics(page)
|
||||
await verifyDiffInteractions(page)
|
||||
|
||||
await page.getByRole('button', { name: '结构压力' }).click()
|
||||
await page.waitForSelector('.preview-surface table', { timeout: 20000 })
|
||||
await page.waitForFunction(() => {
|
||||
return document.querySelectorAll('.preview-surface details').length >= 1
|
||||
&& document.querySelectorAll('.preview-surface summary').length >= 1
|
||||
}, { timeout: 20000 })
|
||||
const stress = await collectStressMetrics(page)
|
||||
const streaming = await collectStreamingMetrics(page)
|
||||
|
||||
await page.getByRole('button', { name: '返回主 demo' }).click()
|
||||
await page.waitForURL(new RegExp(`http://${host}:${port}/$`), { timeout: 15000 })
|
||||
await page.waitForSelector('text=markstream-angular playground', { timeout: 30000 })
|
||||
|
||||
const screenshot = '/tmp/markstream-angular-playground-e2e.png'
|
||||
await page.screenshot({ path: screenshot, fullPage: true })
|
||||
await browser.close()
|
||||
|
||||
const result = {
|
||||
homeUrl,
|
||||
homeProgressText,
|
||||
homeStreamingHealth,
|
||||
homeHoverDuringStreaming,
|
||||
homeThinking,
|
||||
homeLinkTooltip,
|
||||
homeStreamingCodeHighlight,
|
||||
homeCompletion,
|
||||
baseline,
|
||||
thinking,
|
||||
diff,
|
||||
stress,
|
||||
streaming,
|
||||
consoleErrors: summarizeErrors(consoleErrors),
|
||||
pageErrors: summarizeErrors(pageErrors),
|
||||
screenshot,
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (result.consoleErrors.length > 0 || result.pageErrors.length > 0) {
|
||||
throw new Error(`Unexpected browser errors: console=${result.consoleErrors.length}, page=${result.pageErrors.length}`)
|
||||
}
|
||||
if (result.homeStreamingHealth.frameCount < 20 || result.homeStreamingHealth.metaText === result.homeProgressText) {
|
||||
throw new Error(`Home page became unresponsive while streaming: ${JSON.stringify(result.homeStreamingHealth)}`)
|
||||
}
|
||||
if (result.homeCompletion.metaText !== '11268 / 11268 (100%)') {
|
||||
throw new Error(`Home page did not reach full completion: ${JSON.stringify(result.homeCompletion)}`)
|
||||
}
|
||||
if (!result.homeCompletion.containsTaylor || !result.homeCompletion.containsOrthogonalComplement || !result.homeCompletion.containsHelloWorldTail) {
|
||||
throw new Error(`Home page final content is missing expected sections: ${JSON.stringify(result.homeCompletion)}`)
|
||||
}
|
||||
if (result.homeCompletion.katexCount < 10) {
|
||||
throw new Error(`Home page did not keep KaTeX refreshed through the full stream: ${JSON.stringify(result.homeCompletion)}`)
|
||||
}
|
||||
if (
|
||||
result.homeHoverDuringStreaming.interactionCount < 3
|
||||
|| result.homeHoverDuringStreaming.maxOverLatencyMs > 1200
|
||||
|| result.homeHoverDuringStreaming.maxOutLatencyMs > 1200
|
||||
|| result.homeHoverDuringStreaming.maxPlaceholderCount > 0
|
||||
|| result.homeHoverDuringStreaming.finalProbe.mouseoverCount < result.homeHoverDuringStreaming.interactionCount
|
||||
|| result.homeHoverDuringStreaming.finalProbe.mouseoutCount < result.homeHoverDuringStreaming.interactionCount
|
||||
) {
|
||||
throw new Error(`Home hover probe was sluggish while streaming: ${JSON.stringify(result.homeHoverDuringStreaming)}`)
|
||||
}
|
||||
if (
|
||||
result.homeThinking.textLength < 200
|
||||
|| !result.homeThinking.containsComprehensiveAnswer
|
||||
|| result.homeThinking.paragraphCount < 2
|
||||
) {
|
||||
throw new Error(`Home thinking stream did not keep nested content expanded: ${JSON.stringify(result.homeThinking)}`)
|
||||
}
|
||||
if (!result.homeLinkTooltip.visible || !result.homeLinkTooltip.text.includes('https://github.com/Simon-He95/markstream-vue')) {
|
||||
throw new Error(`Home link tooltip did not match the shared singleton behavior: ${JSON.stringify(result.homeLinkTooltip)}`)
|
||||
}
|
||||
if (result.homeStreamingCodeHighlight.monacoCount < 1 || result.homeStreamingCodeHighlight.done) {
|
||||
throw new Error(`Home code blocks did not enter highlighted mode before stream completion: ${JSON.stringify(result.homeStreamingCodeHighlight)}`)
|
||||
}
|
||||
if (baseline.katexCount < 1 || baseline.mermaidSvgCount < 1 || baseline.infographicSvgCount < 1 || baseline.d2SvgCount < 1) {
|
||||
throw new Error(`Baseline enhancements did not all render: ${JSON.stringify(baseline)}`)
|
||||
}
|
||||
if (thinking.thinkingCount < 1 || thinking.mermaidSvgCount < 1 || thinking.monacoCount < 1 || thinking.listCount < 2) {
|
||||
throw new Error(`Thinking sample did not render nested heavy nodes as expected: ${JSON.stringify(thinking)}`)
|
||||
}
|
||||
if (diff.wrapperCount < 2 || diff.monacoCount < 2) {
|
||||
throw new Error(`Diff sample did not mount Monaco editors as expected: ${JSON.stringify(diff)}`)
|
||||
}
|
||||
if (stress.tableCount < 1 || stress.detailsCount < 1 || stress.summaryCount < 1) {
|
||||
throw new Error(`Stress sample missed expected structural nodes: ${JSON.stringify(stress)}`)
|
||||
}
|
||||
if (!(streaming.earlyProgress > 0 && streaming.earlyProgress < 100) || streaming.finalProgress !== 100) {
|
||||
throw new Error(`Streaming progress did not behave as expected: ${JSON.stringify(streaming)}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const recentLogs = server.getLogs().trim()
|
||||
if (recentLogs) {
|
||||
console.error('--- playground-angular recent logs ---')
|
||||
console.error(recentLogs)
|
||||
console.error('--- end playground-angular recent logs ---')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
killProcessTree(server.child)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import net from 'node:net'
|
||||
import process from 'node:process'
|
||||
import { chromium } from 'playwright-core'
|
||||
|
||||
const host = '127.0.0.1'
|
||||
const port = 4230
|
||||
const playground = '/Users/Simon/Github/markstream-vue/playground'
|
||||
|
||||
function isOpen(portNumber) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host, port: portNumber })
|
||||
socket.on('connect', () => {
|
||||
socket.end()
|
||||
resolve(true)
|
||||
})
|
||||
socket.on('error', () => {
|
||||
socket.destroy()
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForPort(portNumber, timeout = 30000) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
if (await isOpen(portNumber))
|
||||
return
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
}
|
||||
throw new Error(`timeout waiting for ${portNumber}`)
|
||||
}
|
||||
|
||||
async function captureStaticState(page) {
|
||||
await page.waitForSelector('.stream-monaco-diff-root', { timeout: 120000 })
|
||||
await page.waitForSelector('.stream-monaco-diff-unchanged-bridge', { timeout: 120000 })
|
||||
|
||||
return page.evaluate(() => {
|
||||
function pickStyle(node) {
|
||||
if (!(node instanceof HTMLElement))
|
||||
return null
|
||||
const style = window.getComputedStyle(node)
|
||||
return {
|
||||
background: style.background,
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderColor: style.borderColor,
|
||||
boxShadow: style.boxShadow,
|
||||
color: style.color,
|
||||
}
|
||||
}
|
||||
|
||||
const bridge = document.querySelector('.stream-monaco-diff-unchanged-bridge')
|
||||
const rail = bridge?.querySelector('.stream-monaco-unchanged-rail')
|
||||
const summary = bridge?.querySelector('.stream-monaco-unchanged-summary')
|
||||
const count = bridge?.querySelector('.stream-monaco-unchanged-count, .stream-monaco-unchanged-metadata-label')
|
||||
const reveal = bridge?.querySelector('.stream-monaco-unchanged-reveal')
|
||||
const diffOverview = document.querySelector('.monaco-diff-editor .diffOverview')
|
||||
const decorationsOverviewRuler = document.querySelector('.decorationsOverviewRuler')
|
||||
const firstLineNumber = document.querySelector('.monaco-diff-editor .editor.modified .line-numbers')
|
||||
const firstViewLine = document.querySelector('.monaco-diff-editor .editor.modified .view-line')
|
||||
const visibleRevealDirections = Array.from(
|
||||
bridge?.querySelectorAll('.stream-monaco-unchanged-reveal') ?? [],
|
||||
)
|
||||
.filter(node => node instanceof HTMLElement && !node.hidden)
|
||||
.map(node => node.dataset.direction ?? null)
|
||||
|
||||
return {
|
||||
bridge: pickStyle(bridge),
|
||||
rail: pickStyle(rail),
|
||||
railClasses: rail instanceof HTMLElement ? Array.from(rail.classList) : [],
|
||||
summary: pickStyle(summary),
|
||||
summaryPadding: summary instanceof HTMLElement
|
||||
? {
|
||||
left: window.getComputedStyle(summary).paddingLeft,
|
||||
right: window.getComputedStyle(summary).paddingRight,
|
||||
}
|
||||
: null,
|
||||
count: count ? window.getComputedStyle(count).color : null,
|
||||
reveal: pickStyle(reveal),
|
||||
visibleRevealDirections,
|
||||
overview: pickStyle(diffOverview),
|
||||
decorationsOverviewRuler: pickStyle(decorationsOverviewRuler),
|
||||
overviewMetrics: diffOverview instanceof HTMLElement
|
||||
? {
|
||||
width: Math.round(diffOverview.getBoundingClientRect().width),
|
||||
display: window.getComputedStyle(diffOverview).display,
|
||||
}
|
||||
: null,
|
||||
decorationsOverviewRulerMetrics: decorationsOverviewRuler instanceof HTMLElement
|
||||
? {
|
||||
width: Math.round(decorationsOverviewRuler.getBoundingClientRect().width),
|
||||
display: window.getComputedStyle(decorationsOverviewRuler).display,
|
||||
}
|
||||
: null,
|
||||
modifiedColumn: (
|
||||
firstLineNumber instanceof HTMLElement
|
||||
&& firstViewLine instanceof HTMLElement
|
||||
&& rail instanceof HTMLElement
|
||||
)
|
||||
? {
|
||||
lineNumberLeft: Math.round(firstLineNumber.getBoundingClientRect().left),
|
||||
lineNumberWidth: Math.round(firstLineNumber.getBoundingClientRect().width),
|
||||
codeLeft: Math.round(firstViewLine.getBoundingClientRect().left),
|
||||
railLeft: Math.round(rail.getBoundingClientRect().left),
|
||||
railWidth: Math.round(rail.getBoundingClientRect().width),
|
||||
}
|
||||
: null,
|
||||
text: summary?.textContent?.trim() ?? null,
|
||||
rootVars: bridge instanceof HTMLElement
|
||||
? {
|
||||
unchangedBg: bridge.style.getPropertyValue('--stream-monaco-unchanged-bg'),
|
||||
unchangedFg: bridge.style.getPropertyValue('--stream-monaco-unchanged-fg'),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function captureExpandedScrollState(page) {
|
||||
await page.click('.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-summary')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await page.evaluate(() => {
|
||||
const scrollable = document.querySelector(
|
||||
'.monaco-diff-editor .editor.modified .monaco-scrollable-element',
|
||||
)
|
||||
if (scrollable instanceof HTMLElement)
|
||||
scrollable.scrollTop = 220
|
||||
})
|
||||
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
return page.evaluate(() => {
|
||||
const bridges = Array.from(
|
||||
document.querySelectorAll('.stream-monaco-diff-unchanged-bridge'),
|
||||
)
|
||||
.filter(node => node instanceof HTMLElement)
|
||||
.map((node) => {
|
||||
const style = window.getComputedStyle(node)
|
||||
const rect = node.getBoundingClientRect()
|
||||
return {
|
||||
hiddenAttr: node.hasAttribute('hidden'),
|
||||
ariaHidden: node.getAttribute('aria-hidden'),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
height: Math.round(rect.height),
|
||||
width: Math.round(rect.width),
|
||||
text: node.textContent?.trim() ?? '',
|
||||
}
|
||||
})
|
||||
|
||||
const visibleBridges = bridges.filter(bridge =>
|
||||
!bridge.hiddenAttr
|
||||
&& bridge.ariaHidden !== 'true'
|
||||
&& bridge.display !== 'none'
|
||||
&& bridge.visibility !== 'hidden'
|
||||
&& bridge.opacity !== '0'
|
||||
&& bridge.height > 0
|
||||
&& bridge.width > 0,
|
||||
)
|
||||
|
||||
const modifiedScrollable = document.querySelector(
|
||||
'.monaco-diff-editor .editor.modified .monaco-scrollable-element',
|
||||
)
|
||||
|
||||
return {
|
||||
bridgeCount: bridges.length,
|
||||
visibleBridgeCount: visibleBridges.length,
|
||||
visibleBridges,
|
||||
modifiedScrollTop:
|
||||
modifiedScrollable instanceof HTMLElement ? modifiedScrollable.scrollTop : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function captureRevealStepState(page) {
|
||||
const parseCount = (text) => {
|
||||
const match = text.match(/\d+/)
|
||||
return match ? Number.parseInt(match[0], 10) : Number.NaN
|
||||
}
|
||||
|
||||
const before = await page.evaluate(() => {
|
||||
const bridge = document.querySelector('.stream-monaco-diff-unchanged-bridge:not([hidden])')
|
||||
const summary = bridge?.querySelector('.stream-monaco-unchanged-summary')
|
||||
const button = bridge?.querySelector('.stream-monaco-unchanged-reveal:not([hidden])')
|
||||
return {
|
||||
label: summary?.textContent?.trim() ?? null,
|
||||
direction: button instanceof HTMLElement ? button.dataset.direction ?? null : null,
|
||||
}
|
||||
})
|
||||
|
||||
await page.click('.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-reveal:not([hidden])')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const after = await page.evaluate(() => {
|
||||
const bridge = document.querySelector('.stream-monaco-diff-unchanged-bridge:not([hidden])')
|
||||
const summary = bridge?.querySelector('.stream-monaco-unchanged-summary')
|
||||
return {
|
||||
label: summary?.textContent?.trim() ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
const beforeCount = parseCount(before.label ?? '')
|
||||
const afterCount = parseCount(after.label ?? '')
|
||||
|
||||
return {
|
||||
before,
|
||||
after,
|
||||
beforeCount,
|
||||
afterCount,
|
||||
delta:
|
||||
Number.isFinite(beforeCount) && Number.isFinite(afterCount)
|
||||
? beforeCount - afterCount
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const vite = spawn(
|
||||
'pnpm',
|
||||
['-C', playground, 'dev', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
|
||||
)
|
||||
|
||||
let logs = ''
|
||||
vite.stdout.on('data', (chunk) => {
|
||||
logs += String(chunk)
|
||||
})
|
||||
vite.stderr.on('data', (chunk) => {
|
||||
logs += String(chunk)
|
||||
})
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } })
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('vmr-test-render-mode', 'monaco')
|
||||
localStorage.setItem('vmr-test-code-stream', 'true')
|
||||
localStorage.setItem('vmr-test-viewport-priority', 'false')
|
||||
localStorage.setItem('vmr-test-batch-rendering', 'false')
|
||||
localStorage.setItem('vmr-test-typewriter', 'false')
|
||||
localStorage.setItem('vmr-test-show-settings', 'true')
|
||||
localStorage.setItem('vmr-test-stream-speed', '4')
|
||||
localStorage.setItem('vmr-test-stream-interval', '24')
|
||||
})
|
||||
|
||||
console.log('goto')
|
||||
page.setDefaultNavigationTimeout(120000)
|
||||
await page.goto(`http://${host}:${port}/diff-line-info-regression`, { waitUntil: 'commit', timeout: 120000 })
|
||||
await page.waitForSelector('h1', { timeout: 120000 })
|
||||
console.log('page-loaded')
|
||||
|
||||
const staticState = await captureStaticState(page)
|
||||
console.log('static-captured')
|
||||
const revealStepState = await captureRevealStepState(page)
|
||||
console.log('reveal-step-captured')
|
||||
const expandedScrollState = await captureExpandedScrollState(page)
|
||||
console.log('expanded-scroll-captured')
|
||||
|
||||
console.log(JSON.stringify({ staticState, revealStepState, expandedScrollState }, null, 2))
|
||||
|
||||
await browser.close()
|
||||
}
|
||||
catch (error) {
|
||||
console.error(String(error))
|
||||
console.error(logs)
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
try {
|
||||
vite.kill('SIGTERM')
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,363 @@
|
||||
#!/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-vue2-cli')
|
||||
const host = '127.0.0.1'
|
||||
|
||||
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 = 3338, end = 3368) {
|
||||
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 startDevServer(port) {
|
||||
const logBuffer = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['exec', 'vue-cli-service', 'serve', '--port', String(port), '--host', host],
|
||||
{
|
||||
cwd: playgroundDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none',
|
||||
CI: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
|
||||
const appendLogs = (chunk) => {
|
||||
const text = String(chunk)
|
||||
logBuffer.push(text)
|
||||
if (logBuffer.length > 120)
|
||||
logBuffer.splice(0, logBuffer.length - 120)
|
||||
}
|
||||
|
||||
child.stdout.on('data', appendLogs)
|
||||
child.stderr.on('data', appendLogs)
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logBuffer.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeColor(color) {
|
||||
return String(color || '').replace(/\s+/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function getColorChannels(color) {
|
||||
const parts = String(color || '').match(/\d+(?:\.\d+)?/g)
|
||||
if (!parts || parts.length < 3)
|
||||
return null
|
||||
return parts.slice(0, 3).map(Number)
|
||||
}
|
||||
|
||||
function getRelativeLuminance(color) {
|
||||
const channels = getColorChannels(color)
|
||||
if (!channels)
|
||||
return null
|
||||
const [r, g, b] = channels.map((channel) => {
|
||||
const value = channel / 255
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
function getContrastRatio(foreground, background) {
|
||||
const fg = getRelativeLuminance(foreground)
|
||||
const bg = getRelativeLuminance(background)
|
||||
if (fg == null || bg == null)
|
||||
return null
|
||||
const lighter = Math.max(fg, bg)
|
||||
const darker = Math.min(fg, bg)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
function isDarkColor(color) {
|
||||
const parts = getColorChannels(color)
|
||||
if (!parts)
|
||||
return false
|
||||
const [r, g, b] = parts
|
||||
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
return luminance < 140
|
||||
}
|
||||
|
||||
async function collectRegression(page) {
|
||||
await page.locator('#delay').fill('4')
|
||||
await page.locator('#chunk').fill('16')
|
||||
await page.getByRole('button', { name: 'Reset' }).click()
|
||||
|
||||
const seenHighlightBlocks = new Set()
|
||||
const regressedBlocks = new Set()
|
||||
const uniqueProgress = new Set()
|
||||
let maxVisibleFallbacks = 0
|
||||
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < 20000) {
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const blocks = Array.from(document.querySelectorAll('.code-block-container')).map((block, index) => {
|
||||
const render = block.querySelector('.code-block-render')
|
||||
const fallback = block.querySelector('.code-fallback-plain')
|
||||
const renderText = render?.textContent?.trim() || ''
|
||||
const fallbackText = fallback?.textContent?.trim() || ''
|
||||
const style = fallback ? window.getComputedStyle(fallback) : null
|
||||
return {
|
||||
index,
|
||||
hasRenderContent: Boolean(renderText.length || render?.children.length),
|
||||
fallbackVisible: Boolean(
|
||||
fallback
|
||||
&& fallbackText.length > 0
|
||||
&& style
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity || '1') > 0.05,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const metaText = document.querySelector('.meta')?.textContent || ''
|
||||
const match = metaText.match(/\((\d+)%\)/)
|
||||
|
||||
return {
|
||||
blocks,
|
||||
progress: match ? Number(match[1]) : null,
|
||||
}
|
||||
})
|
||||
|
||||
if (snapshot.progress != null)
|
||||
uniqueProgress.add(snapshot.progress)
|
||||
|
||||
let visibleFallbacks = 0
|
||||
for (const block of snapshot.blocks) {
|
||||
if (block.hasRenderContent)
|
||||
seenHighlightBlocks.add(block.index)
|
||||
if (block.fallbackVisible)
|
||||
visibleFallbacks += 1
|
||||
if (seenHighlightBlocks.has(block.index) && block.fallbackVisible)
|
||||
regressedBlocks.add(block.index)
|
||||
}
|
||||
maxVisibleFallbacks = Math.max(maxVisibleFallbacks, visibleFallbacks)
|
||||
|
||||
if (snapshot.progress === 100)
|
||||
break
|
||||
|
||||
await page.waitForTimeout(120)
|
||||
}
|
||||
|
||||
return {
|
||||
seenHighlightBlocks: Array.from(seenHighlightBlocks),
|
||||
regressedBlocks: Array.from(regressedBlocks),
|
||||
uniqueProgress: Array.from(uniqueProgress).sort((a, b) => a - b),
|
||||
maxVisibleFallbacks,
|
||||
}
|
||||
}
|
||||
|
||||
async function collectStyleSnapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('.code-block-container')).map((block, index) => {
|
||||
const render = block.querySelector('.code-block-render')
|
||||
const renderHasContent = Boolean(render?.textContent?.trim()?.length || render?.children.length)
|
||||
const titleEl = block.querySelector('.code-block-header .font-mono')
|
||||
const shikiEl = block.querySelector('.code-block-render .shiki, .code-block-render pre')
|
||||
const shikiCodeEl = shikiEl?.querySelector('code') || shikiEl
|
||||
const blockStyle = window.getComputedStyle(block)
|
||||
const shikiStyle = shikiEl ? window.getComputedStyle(shikiEl) : null
|
||||
const codeStyle = shikiCodeEl ? window.getComputedStyle(shikiCodeEl) : null
|
||||
|
||||
return {
|
||||
index,
|
||||
title: titleEl?.textContent?.trim() || '',
|
||||
hasRenderContent: renderHasContent,
|
||||
containerBackground: blockStyle.backgroundColor,
|
||||
containerForeground: blockStyle.color,
|
||||
shikiBackground: shikiStyle?.backgroundColor || '',
|
||||
shikiForeground: codeStyle?.color || shikiStyle?.color || '',
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
|
||||
try {
|
||||
await waitForPort(port, 60000)
|
||||
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1600 } })
|
||||
const consoleErrors = []
|
||||
const pageErrors = []
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error')
|
||||
consoleErrors.push(msg.text())
|
||||
})
|
||||
page.on('pageerror', error => pageErrors.push(String(error)))
|
||||
|
||||
const url = `http://${host}:${port}/`
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('#delay', { timeout: 30000 })
|
||||
await page.waitForSelector('.meta', { timeout: 30000 })
|
||||
|
||||
const regression = await collectRegression(page)
|
||||
await page.waitForTimeout(600)
|
||||
const styleSnapshot = await collectStyleSnapshot(page)
|
||||
const screenshot = '/tmp/vue2-cli-codeblock-highlight-stability.png'
|
||||
await page.screenshot({ path: screenshot, fullPage: true })
|
||||
await browser.close()
|
||||
|
||||
const highlightedBlocks = styleSnapshot.filter(block => block.hasRenderContent)
|
||||
const brightBlocks = highlightedBlocks
|
||||
.filter(block => !isDarkColor(block.shikiBackground || block.containerBackground))
|
||||
.map(block => block.index)
|
||||
const desyncedBackgroundBlocks = highlightedBlocks
|
||||
.filter((block) => {
|
||||
const shikiBg = normalizeColor(block.shikiBackground)
|
||||
const containerBg = normalizeColor(block.containerBackground)
|
||||
return shikiBg && containerBg && shikiBg !== containerBg
|
||||
})
|
||||
.map(block => block.index)
|
||||
const lowContrastBlocks = highlightedBlocks
|
||||
.filter((block) => {
|
||||
const contrast = getContrastRatio(block.containerForeground || block.shikiForeground, block.containerBackground || block.shikiBackground)
|
||||
return contrast != null && contrast < 3
|
||||
})
|
||||
.map(block => block.index)
|
||||
const unnormalizedTitleBlocks = highlightedBlocks
|
||||
.filter(block => /:/.test(block.title) || /shellscript/i.test(block.title))
|
||||
.map(block => ({ index: block.index, title: block.title }))
|
||||
|
||||
const result = {
|
||||
url,
|
||||
...regression,
|
||||
highlightedBlocks: highlightedBlocks.slice(0, 8),
|
||||
brightBlocks,
|
||||
desyncedBackgroundBlocks,
|
||||
lowContrastBlocks,
|
||||
unnormalizedTitleBlocks,
|
||||
consoleErrorCount: consoleErrors.length,
|
||||
pageErrorCount: pageErrors.length,
|
||||
screenshot,
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (result.regressedBlocks.length > 0) {
|
||||
throw new Error(`Detected code block highlight regression on blocks: ${result.regressedBlocks.join(', ')}`)
|
||||
}
|
||||
if (result.seenHighlightBlocks.length === 0) {
|
||||
throw new Error('No highlighted code block was observed during the run')
|
||||
}
|
||||
if (result.consoleErrorCount > 0 || result.pageErrorCount > 0) {
|
||||
throw new Error(`Detected browser errors (console=${result.consoleErrorCount}, page=${result.pageErrorCount})`)
|
||||
}
|
||||
if (result.brightBlocks.length > 0) {
|
||||
throw new Error(`Detected non-dark highlighted code blocks: ${result.brightBlocks.join(', ')}`)
|
||||
}
|
||||
if (result.desyncedBackgroundBlocks.length > 0) {
|
||||
throw new Error(`Detected container/shiki background mismatch on blocks: ${result.desyncedBackgroundBlocks.join(', ')}`)
|
||||
}
|
||||
if (result.lowContrastBlocks.length > 0) {
|
||||
throw new Error(`Detected unreadable code block foreground contrast on blocks: ${result.lowContrastBlocks.join(', ')}`)
|
||||
}
|
||||
if (result.unnormalizedTitleBlocks.length > 0) {
|
||||
throw new Error(`Detected unnormalized code block titles: ${result.unnormalizedTitleBlocks.map(block => `${block.index}:${block.title}`).join(', ')}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const recentLogs = server.getLogs().trim()
|
||||
if (recentLogs) {
|
||||
console.error('--- vue-cli recent logs ---')
|
||||
console.error(recentLogs)
|
||||
console.error('--- end vue-cli recent logs ---')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
killProcessTree(server.child)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,445 @@
|
||||
#!/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')
|
||||
|
||||
function isPortOpen(port, host = '127.0.0.1') {
|
||||
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 = 4173, end = 4199) {
|
||||
for (let port = start; port <= end; port++) {
|
||||
const open = await isPortOpen(port)
|
||||
if (!open)
|
||||
return port
|
||||
}
|
||||
throw new Error(`No free port found in ${start}-${end}`)
|
||||
}
|
||||
|
||||
async function waitForPort(port, ms = 20000) {
|
||||
const start = Date.now()
|
||||
while (true) {
|
||||
const open = await isPortOpen(port)
|
||||
if (open)
|
||||
return
|
||||
if (Date.now() - start > ms)
|
||||
throw new Error(`Timed out waiting for port ${port}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
}
|
||||
}
|
||||
|
||||
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 countLines(text) {
|
||||
return text.split(/\r?\n/).length
|
||||
}
|
||||
|
||||
function buildScenario() {
|
||||
const originalLines = [
|
||||
'type Task = { id: string, status: \'todo\' | \'done\' }',
|
||||
'',
|
||||
'export async function synchronizeTasks(tasks: Task[]) {',
|
||||
' const report: string[] = []',
|
||||
String.raw` report.push(\`input=\${tasks.length}\`)`,
|
||||
'',
|
||||
]
|
||||
|
||||
for (let i = 1; i <= 160; i++) {
|
||||
if (i % 20 === 0)
|
||||
originalLines.push(` // stable checkpoint ${i}`)
|
||||
const n = String(i).padStart(3, '0')
|
||||
originalLines.push(` report.push('stable-${n}')`)
|
||||
}
|
||||
|
||||
originalLines.push('', ' return report', '}')
|
||||
|
||||
const modifiedLines = [...originalLines]
|
||||
|
||||
const optimized = modifiedLines.findIndex(line => line.includes('stable-018'))
|
||||
if (optimized >= 0) {
|
||||
modifiedLines[optimized] = ` report.push('stable-018-optimized')`
|
||||
modifiedLines[optimized + 1] = ` report.push('stable-019-optimized')`
|
||||
modifiedLines[optimized + 2] = ` report.push('stable-020-optimized')`
|
||||
}
|
||||
|
||||
const hotfix = modifiedLines.findIndex(line => line.includes('stable-086'))
|
||||
if (hotfix >= 0) {
|
||||
modifiedLines[hotfix - 1] = ` report.push('stable-085-hotfix')`
|
||||
modifiedLines[hotfix] = ` report.push('stable-086-hotfix')`
|
||||
modifiedLines[hotfix + 1] = ` report.push('stable-087-hotfix')`
|
||||
}
|
||||
|
||||
const cache = modifiedLines.findIndex(line => line.includes('stable-132'))
|
||||
if (cache >= 0) {
|
||||
modifiedLines[cache - 1] = ` report.push('stable-131-cache-hit')`
|
||||
modifiedLines[cache] = ` report.push('stable-132-cache-hit')`
|
||||
modifiedLines[cache + 1] = ` report.push('stable-133-cache-miss')`
|
||||
}
|
||||
|
||||
const returnLine = modifiedLines.findIndex(line => line.trim() === 'return report')
|
||||
if (returnLine >= 0)
|
||||
modifiedLines[returnLine] = ' return report.filter(Boolean)'
|
||||
|
||||
return {
|
||||
original: originalLines.join('\n'),
|
||||
modified: modifiedLines.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiffMarkdown(pair) {
|
||||
const original = pair.original.split(/\r?\n/)
|
||||
const modified = pair.modified.split(/\r?\n/)
|
||||
const max = Math.max(original.length, modified.length)
|
||||
const diffLines = []
|
||||
|
||||
for (let i = 0; i < max; i++) {
|
||||
const left = original[i]
|
||||
const right = modified[i]
|
||||
|
||||
if (left === right) {
|
||||
diffLines.push(left ?? '')
|
||||
continue
|
||||
}
|
||||
if (left != null)
|
||||
diffLines.push(`-${left}`)
|
||||
if (right != null)
|
||||
diffLines.push(`+${right}`)
|
||||
}
|
||||
|
||||
return [
|
||||
'# Diff folding fixture',
|
||||
'',
|
||||
'This page is used by the Playwright smoke test.',
|
||||
'',
|
||||
'```diff ts:synchronizeTasks.ts',
|
||||
...diffLines,
|
||||
'```',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function estimateChangedLines(pair) {
|
||||
const original = pair.original.split(/\r?\n/)
|
||||
const modified = pair.modified.split(/\r?\n/)
|
||||
const max = Math.max(original.length, modified.length)
|
||||
let changed = 0
|
||||
for (let i = 0; i < max; i++) {
|
||||
if ((original[i] ?? '') !== (modified[i] ?? ''))
|
||||
changed++
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHiddenRegions(page, timeout = 20000) {
|
||||
await page.waitForFunction(() => {
|
||||
const hiddenTexts = Array.from(document.querySelectorAll('.diff-hidden-lines'))
|
||||
.map(el => (el.textContent ?? '').trim())
|
||||
.filter(Boolean)
|
||||
return hiddenTexts.some(text => /hidden lines|unchanged|unmodified/i.test(text))
|
||||
}, { timeout })
|
||||
}
|
||||
|
||||
async function collectResult(page, name, extra = {}) {
|
||||
return page.evaluate(({ name, extra }) => {
|
||||
const isVisibleButton = (element) => {
|
||||
if (!(element instanceof HTMLElement))
|
||||
return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = window.getComputedStyle(element)
|
||||
return (
|
||||
rect.width >= 16
|
||||
&& rect.height >= 16
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number.parseFloat(style.opacity || '1') > 0.01
|
||||
)
|
||||
}
|
||||
const roundPx = (value) => {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
const parsePx = (value) => {
|
||||
const n = Number.parseFloat(String(value ?? ''))
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
const diffEditors = Array.from(document.querySelectorAll('.monaco-diff-editor'))
|
||||
const hiddenTexts = Array.from(document.querySelectorAll('.diff-hidden-lines'))
|
||||
.map(el => (el.textContent ?? '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const originalVisibleLines = document.querySelectorAll('.editor.original .view-line').length
|
||||
const modifiedVisibleLines = document.querySelectorAll('.editor.modified .view-line').length
|
||||
const bodyText = document.body.textContent ?? ''
|
||||
const diffEditorMetrics = diffEditors.map((editorEl, index) => {
|
||||
const editorRect = editorEl.getBoundingClientRect()
|
||||
const host = editorEl.closest('.code-editor-container')
|
||||
const hostStyle = host instanceof HTMLElement ? window.getComputedStyle(host) : null
|
||||
const editorHiddenTexts = Array.from(editorEl.querySelectorAll('.diff-hidden-lines'))
|
||||
.map(el => (el.textContent ?? '').trim())
|
||||
.filter(Boolean)
|
||||
const foldButtonCandidates = Array.from(
|
||||
editorEl.querySelectorAll('.markstream-inline-fold-proxy, .diff-hidden-lines a'),
|
||||
)
|
||||
const foldButton
|
||||
= foldButtonCandidates.find(candidate => isVisibleButton(candidate))
|
||||
?? foldButtonCandidates[0]
|
||||
?? null
|
||||
const foldButtonRect = foldButton?.getBoundingClientRect?.() ?? null
|
||||
const hostHeightPx = host instanceof HTMLElement ? roundPx(host.getBoundingClientRect().height) : null
|
||||
const hostMaxHeightPx = parsePx(hostStyle?.maxHeight)
|
||||
const hostOverflow = hostStyle?.overflow ?? null
|
||||
|
||||
return {
|
||||
index,
|
||||
isSideBySide: editorEl.classList.contains('side-by-side'),
|
||||
foldButtonKind: foldButton?.classList.contains('markstream-inline-fold-proxy') ? 'proxy' : (foldButton ? 'native' : null),
|
||||
hiddenRegionTexts: editorHiddenTexts,
|
||||
hostHeightPx,
|
||||
hostInlineHeight: host instanceof HTMLElement ? host.style.height : '',
|
||||
hostMaxHeightPx,
|
||||
hostInlineMaxHeight: host instanceof HTMLElement ? host.style.maxHeight : '',
|
||||
hostOverflow,
|
||||
hostOverflowY: hostStyle?.overflowY ?? null,
|
||||
editorHeightPx: roundPx(editorEl.getBoundingClientRect().height),
|
||||
foldButtonRect: foldButtonRect
|
||||
? {
|
||||
x: roundPx(foldButtonRect.x),
|
||||
y: roundPx(foldButtonRect.y),
|
||||
width: roundPx(foldButtonRect.width),
|
||||
height: roundPx(foldButtonRect.height),
|
||||
}
|
||||
: null,
|
||||
foldButtonVisible: !!(foldButtonRect
|
||||
&& foldButtonRect.width >= 16
|
||||
&& foldButtonRect.height >= 16
|
||||
&& foldButtonRect.left >= editorRect.left - 1
|
||||
&& foldButtonRect.right <= editorRect.right + 1
|
||||
&& foldButtonRect.top >= editorRect.top - 1
|
||||
&& foldButtonRect.bottom <= editorRect.bottom + 1),
|
||||
hostShrunkAfterFolding: editorHiddenTexts.length > 0
|
||||
? !!(hostHeightPx != null && hostMaxHeightPx != null && hostHeightPx + 1 < hostMaxHeightPx && hostOverflow !== 'auto')
|
||||
: null,
|
||||
}
|
||||
})
|
||||
const foldedMetrics = diffEditorMetrics.filter(metric => metric.hiddenRegionTexts.length > 0)
|
||||
const inlineMetrics = foldedMetrics.filter(metric => metric.isSideBySide === false)
|
||||
const foldedHostShrinks = foldedMetrics.length > 0 && foldedMetrics.every(metric => metric.hostShrunkAfterFolding === true)
|
||||
const inlineFoldButtonVisible = inlineMetrics.length > 0 && inlineMetrics.every(metric => metric.foldButtonVisible === true)
|
||||
|
||||
return {
|
||||
name,
|
||||
...extra,
|
||||
diffEditors: diffEditors.length,
|
||||
hiddenRegionCount: hiddenTexts.length,
|
||||
hiddenRegionTexts: hiddenTexts,
|
||||
hasHiddenRegionText: hiddenTexts.some(text => /hidden lines|unchanged|unmodified/i.test(text)),
|
||||
bodyContainsHiddenLinesText: /hidden lines|unchanged|unmodified/i.test(bodyText),
|
||||
originalVisibleLines,
|
||||
modifiedVisibleLines,
|
||||
foldedHostShrinks,
|
||||
inlineFoldButtonVisible,
|
||||
diffEditorMetrics,
|
||||
}
|
||||
}, { name, extra })
|
||||
}
|
||||
|
||||
async function runControlledScenario(context, baseUrl) {
|
||||
const pair = buildScenario()
|
||||
const markdown = buildDiffMarkdown(pair)
|
||||
const page = await context.newPage()
|
||||
const url = `${baseUrl}/test#data=raw:${encodeURIComponent(markdown)}`
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
|
||||
await waitForHiddenRegions(page)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const result = await collectResult(page, 'test-route', {
|
||||
url,
|
||||
originalLineCount: countLines(pair.original),
|
||||
modifiedLineCount: countLines(pair.modified),
|
||||
changedLineEstimate: estimateChangedLines(pair),
|
||||
})
|
||||
|
||||
await page.close()
|
||||
return result
|
||||
}
|
||||
|
||||
async function runHomeScenario(context, baseUrl) {
|
||||
const page = await context.newPage()
|
||||
const url = `${baseUrl}/`
|
||||
const diffEditor = page.locator('.monaco-diff-editor').last()
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
|
||||
await diffEditor.scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(500)
|
||||
await waitForHiddenRegions(page, 30000)
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const result = await collectResult(page, 'index-route', { url })
|
||||
await page.close()
|
||||
return result
|
||||
}
|
||||
|
||||
async function runHomeInlineScenario(context, baseUrl) {
|
||||
const page = await context.newPage()
|
||||
const url = `${baseUrl}/`
|
||||
const diffEditor = page.locator('.monaco-diff-editor').last()
|
||||
|
||||
await page.setViewportSize({ width: 430, height: 900 })
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
|
||||
await diffEditor.scrollIntoViewIfNeeded()
|
||||
await waitForHiddenRegions(page, 30000)
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const initial = await collectResult(page, 'index-route-inline', { url })
|
||||
const foldButton = page.locator('.markstream-inline-fold-proxy:visible, .diff-hidden-lines a:visible').first()
|
||||
await foldButton.click()
|
||||
await page.waitForFunction(() => document.querySelectorAll('.diff-hidden-lines').length === 0, { timeout: 10000 })
|
||||
const afterExpandHiddenRegionCount = await page.locator('.diff-hidden-lines').count()
|
||||
|
||||
await page.close()
|
||||
return {
|
||||
...initial,
|
||||
afterExpandHiddenRegionCount,
|
||||
afterExpandFoldCleared: afterExpandHiddenRegionCount === 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
|
||||
if (!Number.isFinite(port))
|
||||
throw new Error(`Invalid PORT: ${process.env.PORT}`)
|
||||
|
||||
const vite = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'dev', '--host', '127.0.0.1', '--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 context = await browser.newContext()
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem('vmr-settings-stream-delay', '4')
|
||||
localStorage.setItem('vmr-settings-stream-chunk-size', '16')
|
||||
localStorage.setItem('vmr-test-render-mode', 'monaco')
|
||||
localStorage.setItem('vmr-test-code-stream', 'false')
|
||||
localStorage.setItem('vmr-test-viewport-priority', 'false')
|
||||
localStorage.setItem('vmr-test-batch-rendering', 'false')
|
||||
localStorage.setItem('vmr-test-typewriter', 'false')
|
||||
localStorage.setItem('vmr-test-show-settings', 'false')
|
||||
})
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
const controlled = await runControlledScenario(context, baseUrl)
|
||||
const home = await runHomeScenario(context, baseUrl)
|
||||
const homeInline = await runHomeInlineScenario(context, baseUrl)
|
||||
await browser.close()
|
||||
|
||||
const ok = controlled.hasHiddenRegionText && controlled.hiddenRegionCount > 0
|
||||
&& home.hasHiddenRegionText && home.hiddenRegionCount > 0
|
||||
&& home.foldedHostShrinks
|
||||
&& homeInline.hasHiddenRegionText && homeInline.hiddenRegionCount > 0
|
||||
&& homeInline.inlineFoldButtonVisible
|
||||
&& homeInline.afterExpandFoldCleared
|
||||
&& home.diffEditors === 1
|
||||
|
||||
const result = {
|
||||
ok,
|
||||
controlled,
|
||||
home,
|
||||
homeInline,
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (!ok) {
|
||||
console.error('Recent Vite logs:\n', logs.slice(-40).join(''))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,643 @@
|
||||
#!/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'
|
||||
|
||||
const diffSample = [
|
||||
'```diff json:package.json',
|
||||
'{',
|
||||
' "name": "markstream-vue",',
|
||||
'- "version": "0.0.49",',
|
||||
'+ "version": "0.0.54-beta.1",',
|
||||
' "packageManager": "pnpm@10.16.1",',
|
||||
'}',
|
||||
'```',
|
||||
].join('\n')
|
||||
|
||||
const sampleFrameIntervalMs = 25
|
||||
const sampleStreamCharDelayMs = 80
|
||||
const estimatedStreamingDurationMs
|
||||
= diffSample.length * sampleStreamCharDelayMs
|
||||
const samplingDurationMs = Math.max(
|
||||
12000,
|
||||
estimatedStreamingDurationMs + 4000,
|
||||
)
|
||||
const samplingFrameCount = Math.ceil(
|
||||
samplingDurationMs / sampleFrameIntervalMs,
|
||||
)
|
||||
|
||||
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 = 4370, end = 4410) {
|
||||
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, timeoutMs = 60000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
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 startDevServer(port) {
|
||||
const logs = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
child.stdout.on('data', chunk => logs.push(String(chunk)))
|
||||
child.stderr.on('data', chunk => logs.push(String(chunk)))
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logs.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function range(values) {
|
||||
if (!values.length)
|
||||
return null
|
||||
return Math.max(...values) - Math.min(...values)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
|
||||
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: `http://${host}:${port}`,
|
||||
localStorage: [
|
||||
{ name: 'vmr-test-render-mode', value: 'monaco' },
|
||||
{ name: 'vmr-test-code-stream', value: 'true' },
|
||||
{ name: 'vmr-test-stream-chunk-size-min', value: '1' },
|
||||
{ name: 'vmr-test-stream-chunk-size-max', value: '1' },
|
||||
{ name: 'vmr-test-stream-delay-min', value: '80' },
|
||||
{ name: 'vmr-test-stream-delay-max', value: '80' },
|
||||
{ name: 'vmr-test-stream-burstiness', value: '0' },
|
||||
{ name: 'vmr-test-stream-slice-mode', value: 'pure-random' },
|
||||
{ name: 'vmr-test-stream-transport-mode', value: 'scheduler' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const page = await context.newPage()
|
||||
|
||||
await page.goto(`http://${host}:${port}/test`, { waitUntil: 'load' })
|
||||
await page.waitForSelector('.editor-textarea')
|
||||
await page.evaluate((sample) => {
|
||||
const textarea = document.querySelector('.editor-textarea')
|
||||
textarea.value = sample
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}, diffSample)
|
||||
await page.getByRole('button', { name: '开始流式渲染' }).click()
|
||||
await page.waitForSelector('.code-block-container')
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.code-block-container')?.scrollIntoView({
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
})
|
||||
})
|
||||
|
||||
const frames = []
|
||||
for (let index = 0; index < samplingFrameCount; index++) {
|
||||
await page.waitForTimeout(sampleFrameIntervalMs)
|
||||
const frame = await page.evaluate(() => {
|
||||
const preview = document.querySelector('.preview-surface')
|
||||
const viewportCenter = window.innerHeight / 2
|
||||
const diffRoots = Array.from(
|
||||
document.querySelectorAll('.preview-surface .stream-monaco-diff-root'),
|
||||
)
|
||||
const diffRoot = diffRoots
|
||||
.map((root) => {
|
||||
const rect = root.getBoundingClientRect()
|
||||
const visibleHeight = Math.max(
|
||||
0,
|
||||
Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0),
|
||||
)
|
||||
return {
|
||||
root,
|
||||
rect,
|
||||
visibleHeight,
|
||||
distance: Math.abs((rect.top + rect.bottom) / 2 - viewportCenter),
|
||||
}
|
||||
})
|
||||
.filter(entry => entry.visibleHeight > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.visibleHeight !== a.visibleHeight)
|
||||
return b.visibleHeight - a.visibleHeight
|
||||
return a.distance - b.distance
|
||||
})[0]
|
||||
?.root ?? null
|
||||
const block = diffRoot?.closest('.code-block-container') ?? null
|
||||
const diffRootDataset = diffRoot
|
||||
? {
|
||||
streamingInlineControlledRaw: diffRoot.dataset.streamingInlineControlledRaw ?? null,
|
||||
streamingInlineNextSpecCount: diffRoot.dataset.streamingInlineNextSpecCount ?? null,
|
||||
streamingInlineVisibleSpecCount: diffRoot.dataset.streamingInlineVisibleSpecCount ?? null,
|
||||
streamingInlineRenderedSpecCount: diffRoot.dataset.streamingInlineRenderedSpecCount ?? null,
|
||||
streamingInlineNodeCount: diffRoot.dataset.streamingInlineNodeCount ?? null,
|
||||
streamingInlineNodesConnected: diffRoot.dataset.streamingInlineNodesConnected ?? null,
|
||||
streamingInlineReuse: diffRoot.dataset.streamingInlineReuse ?? null,
|
||||
streamingInlineDefer: diffRoot.dataset.streamingInlineDefer ?? null,
|
||||
streamingInlineRenderedMode: diffRoot.dataset.streamingInlineRenderedMode ?? null,
|
||||
}
|
||||
: null
|
||||
const modifiedScroll = diffRoot?.querySelector(
|
||||
'.editor.modified .monaco-scrollable-element.editor-scrollable',
|
||||
) ?? null
|
||||
const diffStatsText = block?.querySelector('.code-diff-stats')?.textContent?.trim() ?? null
|
||||
const versionLine = Array.from(
|
||||
diffRoot?.querySelectorAll('.editor.modified .view-line') ?? [],
|
||||
).find((element) => {
|
||||
if (!(element.textContent || '').includes('0.0.54-beta.1'))
|
||||
return false
|
||||
const style = getComputedStyle(element)
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number.parseFloat(style.opacity || '1') > 0.01
|
||||
})
|
||||
const versionLineDetail = versionLine
|
||||
? (() => {
|
||||
const rect = versionLine.getBoundingClientRect()
|
||||
const style = getComputedStyle(versionLine)
|
||||
const parent = versionLine.parentElement
|
||||
const parentRect = parent?.getBoundingClientRect() ?? null
|
||||
const parentStyle = parent ? getComputedStyle(parent) : null
|
||||
return {
|
||||
text: (versionLine.textContent || '').trim(),
|
||||
top: Math.round(rect.top),
|
||||
height: Math.round(rect.height),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
lineHeight: style.lineHeight,
|
||||
innerHTML: versionLine.innerHTML.slice(0, 300),
|
||||
parentClassName: typeof parent?.className === 'string' ? parent.className : '',
|
||||
parentTop: parentRect ? Math.round(parentRect.top) : null,
|
||||
parentHeight: parentRect ? Math.round(parentRect.height) : null,
|
||||
parentDisplay: parentStyle?.display ?? null,
|
||||
parentVisibility: parentStyle?.visibility ?? null,
|
||||
parentOpacity: parentStyle?.opacity ?? null,
|
||||
}
|
||||
})()
|
||||
: null
|
||||
|
||||
const visibleDiffTexts = Array.from(
|
||||
diffRoot?.querySelectorAll(
|
||||
'.editor.modified .view-line, .editor.modified .stream-monaco-fallback-inline-delete-zone, .editor.modified .inline-deleted-text, .editor.modified .view-lines.line-delete',
|
||||
) ?? [],
|
||||
)
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const text = (element.textContent || '').replace(/\u00A0/g, ' ').trim()
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
text,
|
||||
top: Math.round(rect.top),
|
||||
height: Math.round(rect.height),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
}
|
||||
})
|
||||
.filter(entry =>
|
||||
entry.height > 0
|
||||
&& entry.display !== 'none'
|
||||
&& entry.visibility !== 'hidden'
|
||||
&& Number.parseFloat(entry.opacity || '1') > 0.01
|
||||
&& (entry.text.includes('version')
|
||||
|| entry.text.includes('0.0.49')
|
||||
|| entry.text.includes('0.0.54')),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
if (left.top !== right.top)
|
||||
return left.top - right.top
|
||||
return left.text.localeCompare(right.text)
|
||||
})
|
||||
const uniqueVisibleDiffTexts = []
|
||||
const visibleDiffKeys = new Set()
|
||||
for (const entry of visibleDiffTexts) {
|
||||
const key = `${entry.top}:${entry.text}`
|
||||
if (visibleDiffKeys.has(key))
|
||||
continue
|
||||
visibleDiffKeys.add(key)
|
||||
uniqueVisibleDiffTexts.push(entry)
|
||||
}
|
||||
|
||||
const visibleOldNodes = Array.from(
|
||||
diffRoot?.querySelectorAll(
|
||||
'.editor.modified .stream-monaco-fallback-inline-delete-zone, .editor.modified .stream-monaco-fallback-inline-delete-line, .editor.modified .inline-deleted-text, .editor.modified .view-line.line-delete, .editor.modified .view-lines.line-delete',
|
||||
) ?? [],
|
||||
)
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
text: (element.textContent || '').trim(),
|
||||
className: typeof element.className === 'string' ? element.className : '',
|
||||
parentClassName: typeof element.parentElement?.className === 'string'
|
||||
? element.parentElement.className
|
||||
: '',
|
||||
inViewZones: !!element.closest('.view-zones'),
|
||||
inOverlayRoot: !!element.closest('.stream-monaco-fallback-inline-delete-overlay-root'),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
}
|
||||
})
|
||||
.filter(entry =>
|
||||
entry.width > 0
|
||||
&& entry.height > 0
|
||||
&& entry.display !== 'none'
|
||||
&& entry.visibility !== 'hidden'
|
||||
&& Number.parseFloat(entry.opacity || '1') > 0.01
|
||||
&& entry.text.includes('0.0.49'),
|
||||
)
|
||||
const allFallbackZones = Array.from(
|
||||
diffRoot?.querySelectorAll(
|
||||
'.editor.modified .stream-monaco-fallback-inline-delete-zone, .editor.modified .stream-monaco-fallback-inline-delete-line',
|
||||
) ?? [],
|
||||
)
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
text: (element.textContent || '').trim(),
|
||||
className: typeof element.className === 'string' ? element.className : '',
|
||||
parentClassName: typeof element.parentElement?.className === 'string'
|
||||
? element.parentElement.className
|
||||
: '',
|
||||
inViewZones: !!element.closest('.view-zones'),
|
||||
inOverlayRoot: !!element.closest('.stream-monaco-fallback-inline-delete-overlay-root'),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
top: Math.round(rect.top),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
}
|
||||
})
|
||||
const extraFallbackZones = allFallbackZones.filter((entry) => {
|
||||
return entry.text.length > 0
|
||||
&& !entry.text.includes('"version"')
|
||||
&& !entry.text.includes('0.0.49')
|
||||
})
|
||||
const nearbyModifiedRows = Array.from(
|
||||
diffRoot?.querySelectorAll(
|
||||
'.editor.modified .view-line, .editor.modified .view-zones > div, .editor.modified .margin-view-zones > div',
|
||||
) ?? [],
|
||||
)
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
text: (element.textContent || '').trim(),
|
||||
className: typeof element.className === 'string' ? element.className : '',
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
top: Math.round(rect.top),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
}
|
||||
})
|
||||
.filter(entry => entry.height > 0 && entry.top >= 740 && entry.top <= 880)
|
||||
const visibleViewportRows = Array.from(
|
||||
diffRoot?.querySelectorAll(
|
||||
'.editor.modified .view-line, .editor.modified .view-zones > div, .editor.modified .margin-view-zones > div, .editor.modified .margin-view-overlays > div',
|
||||
) ?? [],
|
||||
)
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
text: (element.textContent || '').trim().slice(0, 160),
|
||||
className: typeof element.className === 'string' ? element.className : '',
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
top: Math.round(rect.top),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
}
|
||||
})
|
||||
.filter(entry => entry.height > 0 && entry.top >= 620 && entry.top <= 1180)
|
||||
|
||||
const sampleVisibleLineParents = Array.from(
|
||||
diffRoot?.querySelectorAll('.editor.modified .view-line') ?? [],
|
||||
)
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
return rect.width > 0
|
||||
&& rect.height > 0
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number.parseFloat(style.opacity || '1') > 0.01
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((element) => {
|
||||
const chain = []
|
||||
let current = element
|
||||
for (let index = 0; index < 6 && current; index++) {
|
||||
chain.push({
|
||||
tag: current.tagName,
|
||||
className: typeof current.className === 'string' ? current.className : '',
|
||||
})
|
||||
current = current.parentElement
|
||||
}
|
||||
return {
|
||||
text: (element.textContent || '').trim().slice(0, 80),
|
||||
chain,
|
||||
}
|
||||
})
|
||||
|
||||
const visibleViewLineGroups = Array.from(
|
||||
diffRoot?.querySelectorAll('.editor.modified .view-lines') ?? [],
|
||||
)
|
||||
.map((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const visibleLines = Array.from(
|
||||
element.querySelectorAll < HTMLElement > ('.view-line'),
|
||||
).filter((line) => {
|
||||
const lineRect = line.getBoundingClientRect()
|
||||
const lineStyle = getComputedStyle(line)
|
||||
return lineRect.width > 0
|
||||
&& lineRect.height > 0
|
||||
&& lineStyle.display !== 'none'
|
||||
&& lineStyle.visibility !== 'hidden'
|
||||
&& Number.parseFloat(lineStyle.opacity || '1') > 0.01
|
||||
}).length
|
||||
const tops = Array.from(
|
||||
element.querySelectorAll < HTMLElement > ('.view-line'),
|
||||
)
|
||||
.map((line) => {
|
||||
const lineRect = line.getBoundingClientRect()
|
||||
const lineStyle = getComputedStyle(line)
|
||||
if (
|
||||
lineRect.width <= 0
|
||||
|| lineRect.height <= 0
|
||||
|| lineStyle.display === 'none'
|
||||
|| lineStyle.visibility === 'hidden'
|
||||
|| Number.parseFloat(lineStyle.opacity || '1') <= 0.01
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return Math.round(lineRect.top)
|
||||
})
|
||||
.filter(top => Number.isFinite(top))
|
||||
return {
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
visibleLineCount: visibleLines,
|
||||
minTop: tops.length ? Math.min(...tops) : null,
|
||||
maxTop: tops.length ? Math.max(...tops) : null,
|
||||
}
|
||||
})
|
||||
.filter(entry =>
|
||||
entry.display !== 'none'
|
||||
&& entry.visibility !== 'hidden'
|
||||
&& Number.parseFloat(entry.opacity || '1') > 0.01
|
||||
&& entry.visibleLineCount > 0,
|
||||
)
|
||||
|
||||
return {
|
||||
hasRoot: !!diffRoot,
|
||||
targetRootTop: diffRoot ? Math.round(diffRoot.getBoundingClientRect().top) : null,
|
||||
streaming: !!diffRoot && diffRoot.classList.contains('stream-monaco-diff-streaming-active'),
|
||||
diffStatsText,
|
||||
diffRootDataset,
|
||||
previewScrollTop: preview ? Math.round(preview.scrollTop) : null,
|
||||
blockTop: block ? Math.round(block.getBoundingClientRect().top) : null,
|
||||
modifiedScrollTop: modifiedScroll ? Math.round(modifiedScroll.scrollTop) : null,
|
||||
versionTop: versionLine ? Math.round(versionLine.getBoundingClientRect().top) : null,
|
||||
versionLineDetail,
|
||||
visibleDiffTexts: uniqueVisibleDiffTexts,
|
||||
visibleOldNodes,
|
||||
allFallbackZones,
|
||||
extraFallbackZones,
|
||||
nearbyModifiedRows,
|
||||
visibleViewportRows,
|
||||
visibleViewLineGroups,
|
||||
sampleVisibleLineParents,
|
||||
}
|
||||
})
|
||||
frames.push(frame)
|
||||
}
|
||||
|
||||
const activeFrames = frames.filter(frame => frame.hasRoot || frame.diffStatsText?.includes('-1'))
|
||||
const firstStablePairIndex = activeFrames.findIndex((frame) => {
|
||||
if (frame.visibleDiffTexts.length !== 2)
|
||||
return false
|
||||
const texts = frame.visibleDiffTexts.map(item => item.text)
|
||||
return texts.some(text => text.includes('"version"') && text.includes('0.0.49'))
|
||||
&& texts.some(text => text.includes('"version"') && text.includes('0.0.54-beta.1'))
|
||||
})
|
||||
const stableFrames = firstStablePairIndex === -1
|
||||
? []
|
||||
: activeFrames.slice(firstStablePairIndex)
|
||||
const oldOnlyFramesBeforePair = (firstStablePairIndex === -1
|
||||
? activeFrames
|
||||
: activeFrames.slice(0, firstStablePairIndex))
|
||||
.filter((frame) => {
|
||||
return frame.visibleDiffTexts.some(item => item.text.includes('0.0.49'))
|
||||
&& !frame.visibleDiffTexts.some(item => item.text.includes('0.0.54-beta.1'))
|
||||
})
|
||||
const newOnlyFramesBeforePair = (firstStablePairIndex === -1
|
||||
? activeFrames
|
||||
: activeFrames.slice(0, firstStablePairIndex))
|
||||
.filter((frame) => {
|
||||
return frame.visibleDiffTexts.some(item => item.text.includes('0.0.54-beta.1'))
|
||||
&& !frame.visibleDiffTexts.some(item => item.text.includes('0.0.49'))
|
||||
})
|
||||
const versionFrames = stableFrames.filter(frame => Number.isFinite(frame.versionTop))
|
||||
const missingVersionFrames = stableFrames.filter(frame => !Number.isFinite(frame.versionTop))
|
||||
const signatureValues = versionFrames.map(frame =>
|
||||
JSON.stringify(frame.visibleDiffTexts.map(item => item.text)),
|
||||
)
|
||||
const zeroDiffStatsFrames = activeFrames.filter((frame) => {
|
||||
if (!frame.streaming)
|
||||
return false
|
||||
const stats = (frame.diffStatsText || '').replace(/\s+/g, '')
|
||||
return stats === '-0+0' || stats === '-0+0…'
|
||||
})
|
||||
const blankViewportFrames = activeFrames.filter(frame =>
|
||||
frame.visibleViewportRows.filter(row => !row.text).length >= 4,
|
||||
)
|
||||
const result = {
|
||||
activeFrameCount: activeFrames.length,
|
||||
stableFrameCount: stableFrames.length,
|
||||
versionFrameCount: versionFrames.length,
|
||||
oldOnlyFramesBeforePair: oldOnlyFramesBeforePair.length,
|
||||
newOnlyFramesBeforePair: newOnlyFramesBeforePair.length,
|
||||
previewScrollTopRange: range(versionFrames.map(frame => frame.previewScrollTop).filter(Number.isFinite)),
|
||||
blockTopRange: range(versionFrames.map(frame => frame.blockTop).filter(Number.isFinite)),
|
||||
modifiedScrollTopRange: range(versionFrames.map(frame => frame.modifiedScrollTop).filter(Number.isFinite)),
|
||||
versionTopRange: range(versionFrames.map(frame => frame.versionTop).filter(Number.isFinite)),
|
||||
diffSignatureCount: new Set(signatureValues).size,
|
||||
duplicateOldVisibleFrames: versionFrames.filter(frame =>
|
||||
frame.visibleOldNodes.filter(node => node.className.includes('stream-monaco-fallback-inline-delete-zone')).length > 1,
|
||||
).length,
|
||||
missingVersionFrames: missingVersionFrames.length,
|
||||
zeroDiffStatsFrames: zeroDiffStatsFrames.length,
|
||||
blankViewportFrames: blankViewportFrames.length,
|
||||
extraFallbackZoneFrames: versionFrames.filter(frame => frame.extraFallbackZones.length > 0).length,
|
||||
multipleVisibleViewLineGroupFrames: versionFrames.filter(frame => frame.visibleViewLineGroups.length > 1).length,
|
||||
badSignatureFrames: versionFrames.filter((frame) => {
|
||||
if (frame.visibleDiffTexts.length !== 2)
|
||||
return true
|
||||
const texts = frame.visibleDiffTexts.map(item => item.text)
|
||||
return !texts.some(text => text.includes('"version"') && text.includes('0.0.49'))
|
||||
|| !texts.some(text => text.includes('"version"') && text.includes('0.0.54-beta.1'))
|
||||
}).length,
|
||||
shiftedFrames: versionFrames
|
||||
.filter(frame =>
|
||||
frame.versionTop !== versionFrames[0]?.versionTop,
|
||||
)
|
||||
.slice(0, 12),
|
||||
firstVersionFrame: versionFrames[0] ?? null,
|
||||
firstActiveFrame: activeFrames[0] ?? null,
|
||||
firstBadOldFrame: versionFrames.find(frame =>
|
||||
frame.visibleOldNodes.filter(node => node.className.includes('stream-monaco-fallback-inline-delete-zone')).length > 1,
|
||||
) ?? null,
|
||||
firstOldOnlyFrame: oldOnlyFramesBeforePair[0] ?? null,
|
||||
firstNewOnlyFrame: newOnlyFramesBeforePair[0] ?? null,
|
||||
firstMissingVersionFrame: missingVersionFrames[0] ?? null,
|
||||
firstZeroDiffStatsFrame: zeroDiffStatsFrames[0] ?? null,
|
||||
firstBlankViewportFrame: blankViewportFrames[0] ?? null,
|
||||
firstBadSignatureFrame: versionFrames.find((frame) => {
|
||||
if (frame.visibleDiffTexts.length !== 2)
|
||||
return true
|
||||
const texts = frame.visibleDiffTexts.map(item => item.text)
|
||||
return !texts.some(text => text.includes('"version"') && text.includes('0.0.49'))
|
||||
|| !texts.some(text => text.includes('"version"') && text.includes('0.0.54-beta.1'))
|
||||
}) ?? null,
|
||||
}
|
||||
|
||||
const ok = result.activeFrameCount >= 12
|
||||
&& result.stableFrameCount > 0
|
||||
&& result.versionFrameCount > 0
|
||||
&& result.oldOnlyFramesBeforePair === 0
|
||||
&& result.newOnlyFramesBeforePair === 0
|
||||
&& result.previewScrollTopRange === 0
|
||||
&& result.blockTopRange === 0
|
||||
&& result.modifiedScrollTopRange === 0
|
||||
&& result.missingVersionFrames === 0
|
||||
&& result.zeroDiffStatsFrames === 0
|
||||
&& result.blankViewportFrames === 0
|
||||
&& result.versionTopRange === 0
|
||||
&& result.diffSignatureCount === 1
|
||||
&& result.duplicateOldVisibleFrames === 0
|
||||
&& result.extraFallbackZoneFrames === 0
|
||||
&& result.multipleVisibleViewLineGroupFrames === 0
|
||||
&& result.badSignatureFrames === 0
|
||||
|
||||
console.log(JSON.stringify({ ok, ...result }, null, 2))
|
||||
|
||||
await context.close()
|
||||
await browser.close()
|
||||
killProcessTree(server.child)
|
||||
process.exit(ok ? 0 : 1)
|
||||
}
|
||||
catch (error) {
|
||||
killProcessTree(server.child)
|
||||
console.error(server.getLogs())
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/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)
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/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 = 4200, end = 4240) {
|
||||
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, timeoutMs = 60000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
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 startDevServer(port) {
|
||||
const logs = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
child.stdout.on('data', chunk => logs.push(String(chunk)))
|
||||
child.stderr.on('data', chunk => logs.push(String(chunk)))
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logs.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function runScenario(page, sourceMode, codeRendererMode) {
|
||||
return page.evaluate(async ({ sourceMode, codeRendererMode }) => {
|
||||
const api = window.__heightEstimationExperiment
|
||||
if (!api)
|
||||
throw new Error('Missing __heightEstimationExperiment API.')
|
||||
|
||||
await api.setSourceMode(sourceMode)
|
||||
await api.setCodeRendererMode(codeRendererMode)
|
||||
await api.waitUntilReady()
|
||||
|
||||
const summaryInitial = api.getSummary()
|
||||
const restore = await api.runRestoreTrial()
|
||||
const loading = sourceMode === 'nodes' ? await api.runLoadingReadyTrial() : null
|
||||
const resize = await api.runResizeTrial([375, 768, 1280])
|
||||
await api.waitUntilReady()
|
||||
const summaryFinal = api.getSummary()
|
||||
|
||||
return {
|
||||
sourceMode,
|
||||
codeRendererMode,
|
||||
summaryInitial,
|
||||
summaryFinal,
|
||||
restore,
|
||||
loading,
|
||||
resize,
|
||||
reports: api.getReports(),
|
||||
}
|
||||
}, { sourceMode, codeRendererMode })
|
||||
}
|
||||
|
||||
function absoluteOrNull(value) {
|
||||
return value == null ? null : Math.abs(Number(value))
|
||||
}
|
||||
|
||||
function assertScenario(result) {
|
||||
const id = `${result.sourceMode}/${result.codeRendererMode}`
|
||||
const summary = result.summaryFinal
|
||||
if (!summary)
|
||||
throw new Error(`[${id}] Missing summary.`)
|
||||
|
||||
if (!(summary.actualTotalHeight > 0))
|
||||
throw new Error(`[${id}] Actual total height must be > 0.`)
|
||||
|
||||
const restoreDrift = absoluteOrNull(result.restore?.t500?.experiment)
|
||||
if (restoreDrift == null)
|
||||
throw new Error(`[${id}] Missing experiment restore drift.`)
|
||||
|
||||
if (!Array.isArray(result.resize) || result.resize.length !== 3)
|
||||
throw new Error(`[${id}] Resize trial did not return 3 samples.`)
|
||||
|
||||
if (!(summary.experimentError < summary.baselineError))
|
||||
throw new Error(`[${id}] Experiment error should be lower than baseline.`)
|
||||
|
||||
if (!(restoreDrift <= 8))
|
||||
throw new Error(`[${id}] Experiment restore drift should stay within 8px at t500.`)
|
||||
|
||||
for (const sample of result.resize) {
|
||||
const drift = absoluteOrNull(sample?.experiment)
|
||||
if (drift == null)
|
||||
throw new Error(`[${id}] Missing resize drift for width ${sample?.width ?? 'unknown'}.`)
|
||||
if (!(drift <= 12))
|
||||
throw new Error(`[${id}] Resize drift should stay within 12px (width ${sample.width}).`)
|
||||
}
|
||||
|
||||
if (result.sourceMode === 'nodes' && !result.loading)
|
||||
throw new Error(`[${id}] Missing nodes loading trial result.`)
|
||||
|
||||
if (result.sourceMode === 'nodes') {
|
||||
const loadingDelta = absoluteOrNull(result.loading?.experiment)
|
||||
if (loadingDelta == null)
|
||||
throw new Error(`[${id}] Missing experiment loading delta.`)
|
||||
if (!(loadingDelta <= 16))
|
||||
throw new Error(`[${id}] Loading transition should stay within 16px.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
const cleanup = () => killProcessTree(server.child)
|
||||
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: 1600, height: 1200 } })
|
||||
await page.goto(`http://${host}:${port}/height-estimation-experiment`, { waitUntil: 'load' })
|
||||
|
||||
const scenarios = []
|
||||
for (const sourceMode of ['nodes', 'content']) {
|
||||
for (const codeRendererMode of ['markdown', 'monaco'])
|
||||
scenarios.push(await runScenario(page, sourceMode, codeRendererMode))
|
||||
}
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
try {
|
||||
assertScenario(scenario)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[e2e-height-estimation-experiment] scenario failure')
|
||||
console.error(JSON.stringify({
|
||||
sourceMode: scenario.sourceMode,
|
||||
codeRendererMode: scenario.codeRendererMode,
|
||||
summaryInitial: scenario.summaryInitial,
|
||||
summaryFinal: scenario.summaryFinal,
|
||||
restore: scenario.restore,
|
||||
loading: scenario.loading,
|
||||
resize: scenario.resize,
|
||||
}, null, 2))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const report = { nodes: {}, content: {} }
|
||||
for (const scenario of scenarios) {
|
||||
report[scenario.sourceMode][scenario.codeRendererMode] = {
|
||||
initial: scenario.summaryInitial,
|
||||
final: scenario.summaryFinal,
|
||||
restore: scenario.restore,
|
||||
loading: scenario.loading,
|
||||
resize: scenario.resize,
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
|
||||
await browser.close()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[e2e-height-estimation-experiment] failed')
|
||||
console.error(error)
|
||||
console.error(server.getLogs())
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/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'
|
||||
|
||||
const issueMarkdown = `- 代入数据:$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$,$m=1.1\\ \\text{kg}$,$\\Delta t=40℃$
|
||||
- 计算得:$Q_1=0.75\\times10^3\\ \\text{J/(kg·℃)}\\times1.1\\ \\text{kg}\\times40℃=3.3\\times 10^{4}\\ \\text{J}$
|
||||
- 第(1)问:学生遗漏了比热容的数量级$10^3$,将$0.75\\times10^3\\ \\text{J/(kg·℃)}$错写为$0.75\\ \\text{J/(kg·℃)}$,导致计算出的$Q_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 = 4291, end = 4330) {
|
||||
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, timeoutMs = 60000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
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 startDevServer(port) {
|
||||
const logs = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
child.stdout.on('data', chunk => logs.push(String(chunk)))
|
||||
child.stderr.on('data', chunk => logs.push(String(chunk)))
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logs.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createIssueUrl(port) {
|
||||
return `http://${host}:${port}/test#data=raw:${encodeURIComponent(issueMarkdown)}`
|
||||
}
|
||||
|
||||
async function waitForIssueMath(page) {
|
||||
await page.locator('.preview-surface').waitFor({ state: 'visible', timeout: 30000 })
|
||||
await page.waitForFunction(() => {
|
||||
const root = document.querySelector('.preview-surface')
|
||||
if (!root)
|
||||
return false
|
||||
|
||||
const inlineNodes = Array.from(root.querySelectorAll('[data-markstream-math="inline"]'))
|
||||
if (inlineNodes.length !== 8)
|
||||
return false
|
||||
|
||||
return inlineNodes.every((node) => {
|
||||
const mode = node.getAttribute('data-markstream-mode')
|
||||
return mode === 'katex'
|
||||
&& Boolean(node.querySelector('.katex'))
|
||||
&& !node.querySelector('.math-inline--fallback')
|
||||
&& !node.querySelector('.math-inline__loading')
|
||||
})
|
||||
}, { timeout: 30000 })
|
||||
}
|
||||
|
||||
async function readSummary(page) {
|
||||
return page.evaluate(() => {
|
||||
const root = document.querySelector('.preview-surface')
|
||||
const inlineNodes = Array.from(root?.querySelectorAll('[data-markstream-math="inline"]') || [])
|
||||
return {
|
||||
inlineCount: inlineNodes.length,
|
||||
inlineModes: inlineNodes.map(node => node.getAttribute('data-markstream-mode')),
|
||||
katexCount: root?.querySelectorAll('[data-markstream-math="inline"] .katex').length || 0,
|
||||
fallbackInlineCount: root?.querySelectorAll('.math-inline--fallback').length || 0,
|
||||
fallbackBlockCount: root?.querySelectorAll('.math-block__fallback').length || 0,
|
||||
loadingInlineCount: root?.querySelectorAll('.math-inline__loading').length || 0,
|
||||
katexErrorCount: root?.querySelectorAll('.katex-error').length || 0,
|
||||
previewText: root?.textContent || '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
let browser
|
||||
let page
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 1200 } })
|
||||
|
||||
const relevantConsoleIssues = []
|
||||
const pageErrors = []
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if ((msg.type() === 'error' || msg.type() === 'warning') && /katex|cdotp|character metrics|undefined control sequence/i.test(msg.text()))
|
||||
relevantConsoleIssues.push(`[${msg.type()}] ${msg.text()}`)
|
||||
})
|
||||
page.on('pageerror', error => pageErrors.push(String(error?.message || error)))
|
||||
|
||||
await page.goto(createIssueUrl(port), { waitUntil: 'load' })
|
||||
await waitForIssueMath(page)
|
||||
|
||||
const summary = await readSummary(page)
|
||||
|
||||
if (summary.inlineCount !== 8)
|
||||
throw new Error(`Expected 8 inline math nodes, got ${summary.inlineCount}`)
|
||||
if (summary.katexCount !== 8)
|
||||
throw new Error(`Expected 8 rendered KaTeX nodes, got ${summary.katexCount}`)
|
||||
if (summary.inlineModes.some(mode => mode !== 'katex'))
|
||||
throw new Error(`Expected all inline math nodes to be in katex mode, got ${summary.inlineModes.join(', ')}`)
|
||||
if (summary.fallbackInlineCount !== 0 || summary.fallbackBlockCount !== 0)
|
||||
throw new Error(`Unexpected fallback nodes: inline=${summary.fallbackInlineCount}, block=${summary.fallbackBlockCount}`)
|
||||
if (summary.loadingInlineCount !== 0)
|
||||
throw new Error(`Unexpected loading math nodes left in preview: ${summary.loadingInlineCount}`)
|
||||
if (summary.katexErrorCount !== 0)
|
||||
throw new Error(`Unexpected KaTeX error nodes in preview: ${summary.katexErrorCount}`)
|
||||
if (summary.previewText.includes('$c=0.75\\times10^3\\ \\text{J/(kg·℃)}$'))
|
||||
throw new Error('Preview still contains raw fallback text for the first issue formula')
|
||||
if (relevantConsoleIssues.length)
|
||||
throw new Error(`Unexpected KaTeX console issues:\n${relevantConsoleIssues.join('\n')}`)
|
||||
if (pageErrors.length)
|
||||
throw new Error(`Unexpected page errors:\n${pageErrors.join('\n')}`)
|
||||
|
||||
console.log('KaTeX unicode-unit e2e passed.')
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
catch (error) {
|
||||
if (page) {
|
||||
try {
|
||||
const screenshot = '/tmp/markstream-katex-unicode-unit-regression.png'
|
||||
await page.screenshot({ path: screenshot, fullPage: true })
|
||||
console.error(`Saved failure screenshot to ${screenshot}`)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
console.error(server.getLogs())
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
await browser?.close().catch(() => {})
|
||||
killProcessTree(server.child)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[e2e-katex-unicode-unit-regression] failed')
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
#!/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-vue2-cli')
|
||||
const host = '127.0.0.1'
|
||||
|
||||
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 = 3338, end = 3368) {
|
||||
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 = 60000) {
|
||||
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 startDevServer(port) {
|
||||
const logBuffer = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['exec', 'vue-cli-service', 'serve', '--port', String(port), '--host', host],
|
||||
{
|
||||
cwd: playgroundDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none',
|
||||
CI: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
|
||||
const appendLogs = (chunk) => {
|
||||
const text = String(chunk)
|
||||
logBuffer.push(text)
|
||||
if (logBuffer.length > 120)
|
||||
logBuffer.splice(0, logBuffer.length - 120)
|
||||
}
|
||||
|
||||
child.stdout.on('data', appendLogs)
|
||||
child.stderr.on('data', appendLogs)
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logBuffer.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readProgress(page) {
|
||||
return page.evaluate(() => {
|
||||
const metaText = document.querySelector('.meta')?.textContent || ''
|
||||
const match = metaText.match(/\((\d+)%\)/)
|
||||
return match ? Number(match[1]) : null
|
||||
})
|
||||
}
|
||||
|
||||
async function readTooltipState(page) {
|
||||
return page.evaluate(() => {
|
||||
const tooltip = document.querySelector('.tooltip-element')
|
||||
const style = tooltip ? window.getComputedStyle(tooltip) : null
|
||||
const text = tooltip?.textContent?.trim() || ''
|
||||
const visible = Boolean(
|
||||
tooltip
|
||||
&& text.length > 0
|
||||
&& style
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity || '1') > 0.05,
|
||||
)
|
||||
|
||||
return {
|
||||
visible,
|
||||
text,
|
||||
html: tooltip?.innerHTML || '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function hoverAndReadTooltip(page, locator, expectedText) {
|
||||
await locator.scrollIntoViewIfNeeded()
|
||||
await locator.hover()
|
||||
await page.waitForFunction(() => {
|
||||
const tooltip = document.querySelector('.tooltip-element')
|
||||
if (!tooltip)
|
||||
return false
|
||||
const style = window.getComputedStyle(tooltip)
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity || '1') > 0.05
|
||||
&& Boolean(tooltip.textContent?.trim())
|
||||
}, { timeout: 10000 })
|
||||
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < 4000) {
|
||||
const state = await readTooltipState(page)
|
||||
if (state.visible && state.text.includes(expectedText))
|
||||
return state
|
||||
await page.waitForTimeout(80)
|
||||
}
|
||||
|
||||
const state = await readTooltipState(page)
|
||||
throw new Error(`Tooltip text mismatch. Expected to include "${expectedText}", got "${state.text}".`)
|
||||
}
|
||||
|
||||
async function collectTooltipRegression(page) {
|
||||
await page.locator('#delay').fill('4')
|
||||
await page.locator('#chunk').fill('16')
|
||||
await page.getByRole('button', { name: 'Reset' }).click()
|
||||
|
||||
const firstLink = page.locator('a.link-node[href="https://github.com/Simon-He95/markstream-vue"]').filter({ hasText: 'Star on GitHub' }).first()
|
||||
const secondLink = page.locator('a.link-node[href="https://simonhe.me/"]').filter({ hasText: 'Link (Test 1)' }).first()
|
||||
|
||||
await firstLink.waitFor({ state: 'visible', timeout: 15000 })
|
||||
const firstProgress = await readProgress(page)
|
||||
const firstTooltip = await hoverAndReadTooltip(page, firstLink, 'https://github.com/Simon-He95/markstream-vue')
|
||||
|
||||
await secondLink.waitFor({ state: 'visible', timeout: 15000 })
|
||||
const secondTooltip = await hoverAndReadTooltip(page, secondLink, 'https://simonhe.me/')
|
||||
const progressAtMonitorStart = await readProgress(page)
|
||||
|
||||
const samples = []
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < 20000) {
|
||||
const tooltip = await readTooltipState(page)
|
||||
const progress = await readProgress(page)
|
||||
samples.push({ tooltip, progress })
|
||||
|
||||
if (progress === 100 && samples.length >= 10)
|
||||
break
|
||||
|
||||
await page.waitForTimeout(120)
|
||||
}
|
||||
|
||||
const hiddenSamples = samples.filter(sample => !sample.tooltip.visible).length
|
||||
const wrongTextSamples = samples.filter(sample => !sample.tooltip.text.includes('https://simonhe.me/')).length
|
||||
const finalProgress = samples.at(-1)?.progress ?? null
|
||||
const progressAdvanced = progressAtMonitorStart != null && finalProgress != null
|
||||
? finalProgress - progressAtMonitorStart
|
||||
: null
|
||||
|
||||
return {
|
||||
firstProgress,
|
||||
progressAtMonitorStart,
|
||||
finalProgress,
|
||||
progressAdvanced,
|
||||
firstTooltipText: firstTooltip.text,
|
||||
secondTooltipText: secondTooltip.text,
|
||||
hiddenSamples,
|
||||
wrongTextSamples,
|
||||
sampleCount: samples.length,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1600 } })
|
||||
const consoleErrors = []
|
||||
const pageErrors = []
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error')
|
||||
consoleErrors.push(msg.text())
|
||||
})
|
||||
page.on('pageerror', error => pageErrors.push(String(error)))
|
||||
|
||||
const url = `http://${host}:${port}/?probe=1`
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('#delay', { timeout: 30000 })
|
||||
await page.waitForSelector('.meta', { timeout: 30000 })
|
||||
|
||||
const regression = await collectTooltipRegression(page)
|
||||
const screenshot = '/tmp/vue2-cli-link-tooltip-streaming.png'
|
||||
await page.screenshot({ path: screenshot, fullPage: true })
|
||||
await browser.close()
|
||||
|
||||
const result = {
|
||||
url,
|
||||
...regression,
|
||||
consoleErrorCount: consoleErrors.length,
|
||||
pageErrorCount: pageErrors.length,
|
||||
screenshot,
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (result.hiddenSamples > 0)
|
||||
throw new Error(`Tooltip became hidden during streaming (${result.hiddenSamples} hidden samples).`)
|
||||
if (result.wrongTextSamples > 0)
|
||||
throw new Error(`Tooltip content got stuck or stale during streaming (${result.wrongTextSamples} wrong-text samples).`)
|
||||
if (result.finalProgress !== 100)
|
||||
throw new Error(`Streaming did not finish while tooltip was monitored (ended at ${result.finalProgress ?? 'n/a'}%).`)
|
||||
if (result.consoleErrorCount > 0 || result.pageErrorCount > 0)
|
||||
throw new Error(`Unexpected browser errors: console=${result.consoleErrorCount}, page=${result.pageErrorCount}`)
|
||||
}
|
||||
catch (error) {
|
||||
const recentLogs = server.getLogs().trim()
|
||||
if (recentLogs) {
|
||||
console.error('--- vue-cli recent logs ---')
|
||||
console.error(recentLogs)
|
||||
console.error('--- end vue-cli recent logs ---')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
killProcessTree(server.child)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/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 = 4321, end = 4360) {
|
||||
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, timeoutMs = 60000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
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 startDevServer(port) {
|
||||
const logs = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
child.stdout.on('data', chunk => logs.push(String(chunk)))
|
||||
child.stderr.on('data', chunk => logs.push(String(chunk)))
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logs.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForVisibleBlocksReady(page, rootSelector) {
|
||||
await page.waitForFunction((selector) => {
|
||||
const root = document.querySelector(selector)
|
||||
if (!root)
|
||||
return false
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const isVisible = (element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
|
||||
}
|
||||
const visibleCodeBlocks = Array.from(document.querySelectorAll('.code-block-container')).filter(isVisible)
|
||||
const visibleMermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]')).filter(isVisible)
|
||||
const visibleInfographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]')).filter(isVisible)
|
||||
return visibleCodeBlocks.every(element => !element.querySelector('.code-fallback-plain, .code-pre-fallback'))
|
||||
&& visibleMermaids.every(element => element.querySelector('svg'))
|
||||
&& visibleInfographics.every(element => element.querySelector('svg'))
|
||||
}, rootSelector, { timeout: 30000 })
|
||||
}
|
||||
|
||||
async function waitForAllD2Ready(page, timeout = 15000) {
|
||||
await page.waitForFunction(() => {
|
||||
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
|
||||
return d2Blocks.every(element => element.querySelector('.d2-svg svg'))
|
||||
}, null, { timeout })
|
||||
}
|
||||
|
||||
async function scrollThroughRoot(page, rootSelector) {
|
||||
while (true) {
|
||||
await waitForVisibleBlocksReady(page, rootSelector)
|
||||
const state = await page.evaluate((selector) => {
|
||||
const root = document.querySelector(selector)
|
||||
if (!root)
|
||||
return { done: true }
|
||||
root.style.scrollBehavior = 'auto'
|
||||
const maxScrollTop = Math.max(0, root.scrollHeight - root.clientHeight)
|
||||
const step = Math.max(320, Math.round(root.clientHeight * 0.85))
|
||||
const nextScrollTop = Math.min(maxScrollTop, root.scrollTop + step)
|
||||
const done = nextScrollTop <= root.scrollTop + 1
|
||||
if (!done)
|
||||
root.scrollTop = nextScrollTop
|
||||
return { done }
|
||||
}, rootSelector)
|
||||
if (state.done)
|
||||
break
|
||||
await page.waitForTimeout(180)
|
||||
}
|
||||
await waitForVisibleBlocksReady(page, rootSelector)
|
||||
}
|
||||
|
||||
async function collectMetrics(page) {
|
||||
const rootSelector = '.chatbot-messages'
|
||||
await page.goto('/', { waitUntil: 'load' })
|
||||
await page.locator('.chatbot-container').waitFor({ state: 'visible', timeout: 15000 })
|
||||
await waitForVisibleBlocksReady(page, rootSelector)
|
||||
await page.waitForTimeout(250)
|
||||
|
||||
const initial = await page.evaluate(() => {
|
||||
const state = window.__mainPlaygroundPerf ?? {}
|
||||
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
|
||||
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
|
||||
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
|
||||
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
|
||||
const root = document.querySelector('.chatbot-messages')
|
||||
const rootRect = root?.getBoundingClientRect()
|
||||
const isVisible = (element) => {
|
||||
if (!rootRect)
|
||||
return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
|
||||
}
|
||||
const visibleMermaids = mermaids.filter(isVisible)
|
||||
const visibleInfographics = infographics.filter(isVisible)
|
||||
const visibleD2Blocks = d2Blocks.filter(isVisible)
|
||||
const visibleCodeBlocks = Array.from(document.querySelectorAll('.code-block-container')).filter(isVisible)
|
||||
return {
|
||||
lcpMs: Number(state.lcpMs ?? 0),
|
||||
lcpElement: state.lcpElement ?? null,
|
||||
cls: Number(state.cls ?? 0),
|
||||
firstPaintMs: Number(state.paints?.paint ?? 0),
|
||||
firstContentfulPaintMs: Number(state.paints?.['first-contentful-paint'] ?? 0),
|
||||
longTaskCount: longTasks.length,
|
||||
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
|
||||
longTaskMaxMs: longTasks.length ? Math.max(...longTasks) : 0,
|
||||
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
|
||||
codeBlockCount: document.querySelectorAll('.code-block-container').length,
|
||||
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
|
||||
visibleCodeBlockCount: visibleCodeBlocks.length,
|
||||
visibleFallbackCount: visibleCodeBlocks.filter(element => element.querySelector('.code-fallback-plain, .code-pre-fallback')).length,
|
||||
mermaidCount: mermaids.length,
|
||||
renderedMermaidCount: mermaids.filter(element => element.querySelector('svg')).length,
|
||||
visibleMermaidCount: visibleMermaids.length,
|
||||
visibleRenderedMermaidCount: visibleMermaids.filter(element => element.querySelector('svg')).length,
|
||||
infographicCount: infographics.length,
|
||||
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
|
||||
visibleInfographicCount: visibleInfographics.length,
|
||||
visibleRenderedInfographicCount: visibleInfographics.filter(element => element.querySelector('svg')).length,
|
||||
d2Count: d2Blocks.length,
|
||||
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
|
||||
visibleD2Count: visibleD2Blocks.length,
|
||||
visibleRenderedD2Count: visibleD2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
|
||||
topLayoutShifts: Array.isArray(state.layoutShifts)
|
||||
? [...state.layoutShifts]
|
||||
.sort((a, b) => Number(b?.value || 0) - Number(a?.value || 0))
|
||||
.slice(0, 5)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
|
||||
await scrollThroughRoot(page, rootSelector)
|
||||
await waitForAllD2Ready(page)
|
||||
await page.waitForTimeout(250)
|
||||
|
||||
const fullScroll = await page.evaluate(() => {
|
||||
const state = window.__mainPlaygroundPerf ?? {}
|
||||
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
|
||||
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
|
||||
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
|
||||
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
|
||||
return {
|
||||
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
|
||||
codeBlockCount: document.querySelectorAll('.code-block-container').length,
|
||||
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
|
||||
mermaidCount: mermaids.length,
|
||||
renderedMermaidCount: mermaids.filter(element => element.querySelector('svg')).length,
|
||||
infographicCount: infographics.length,
|
||||
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
|
||||
d2Count: d2Blocks.length,
|
||||
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
|
||||
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
|
||||
}
|
||||
})
|
||||
|
||||
const replayButton = page.locator('button[title="Pause streaming"], button[title="Resume streaming"]')
|
||||
await replayButton.waitFor({ state: 'visible', timeout: 5000 })
|
||||
if ((await replayButton.getAttribute('title')) === 'Pause streaming') {
|
||||
await replayButton.click()
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.querySelector('button[title="Resume streaming"]')
|
||||
return Boolean(button)
|
||||
}, null, { timeout: 5000 })
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const state = window.__mainPlaygroundPerf
|
||||
if (state) {
|
||||
state.replayStartedAt = performance.now()
|
||||
state.replayLongTaskCountBaseline = state.longTasks.length
|
||||
state.replayLongTaskTotalBaseline = state.longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0)
|
||||
}
|
||||
})
|
||||
await page.locator('button[title="Resume streaming"]').click()
|
||||
await waitForVisibleBlocksReady(page, rootSelector)
|
||||
await page.waitForTimeout(250)
|
||||
|
||||
const replay = await page.evaluate(() => {
|
||||
const state = window.__mainPlaygroundPerf ?? {}
|
||||
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
|
||||
const totalLongTaskMs = longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0)
|
||||
return {
|
||||
settleTimeMs: performance.now() - Number(state.replayStartedAt ?? performance.now()),
|
||||
longTaskCount: longTasks.length - Number(state.replayLongTaskCountBaseline ?? 0),
|
||||
longTaskTotalMs: totalLongTaskMs - Number(state.replayLongTaskTotalBaseline ?? 0),
|
||||
}
|
||||
})
|
||||
|
||||
return { initial, fullScroll, replay }
|
||||
}
|
||||
|
||||
function assertScenario(result) {
|
||||
if (!(result.initial.visibleFallbackCount === 0))
|
||||
throw new Error(`Visible code block fallbacks should be gone after initial settle. Got ${result.initial.visibleFallbackCount}.`)
|
||||
if (result.initial.visibleRenderedMermaidCount !== result.initial.visibleMermaidCount)
|
||||
throw new Error('Visible mermaid blocks should finish in preview mode after initial settle.')
|
||||
if (result.initial.visibleRenderedInfographicCount !== result.initial.visibleInfographicCount)
|
||||
throw new Error('Visible infographic blocks should finish after initial settle.')
|
||||
if (!(result.initial.lcpMs > 0))
|
||||
throw new Error('Expected a positive LCP measurement.')
|
||||
if (!(result.initial.lcpMs <= 4000))
|
||||
throw new Error(`LCP should stay within 4000ms. Got ${result.initial.lcpMs}.`)
|
||||
if (!(result.initial.cls <= 0.05))
|
||||
throw new Error(`CLS should stay within 0.05. Got ${result.initial.cls}.`)
|
||||
if (!(result.initial.longTaskMaxMs <= 500))
|
||||
throw new Error(`Max long task should stay within 500ms. Got ${result.initial.longTaskMaxMs}.`)
|
||||
if (!(result.initial.longTaskTotalMs <= 1400))
|
||||
throw new Error(`Total long task time should stay within 1400ms. Got ${result.initial.longTaskTotalMs}.`)
|
||||
if (!(result.initial.settleTimeMs <= 7000))
|
||||
throw new Error(`Initial settle should stay within 7000ms. Got ${result.initial.settleTimeMs}.`)
|
||||
if (result.fullScroll.fallbackCount !== 0)
|
||||
throw new Error('Code block fallbacks should be gone after full scroll settle.')
|
||||
if (result.fullScroll.renderedMermaidCount !== result.fullScroll.mermaidCount)
|
||||
throw new Error('All mermaid blocks should finish after full scroll settle.')
|
||||
if (result.fullScroll.renderedInfographicCount !== result.fullScroll.infographicCount)
|
||||
throw new Error('All infographic blocks should finish after full scroll settle.')
|
||||
if (result.fullScroll.d2Count > 0 && result.fullScroll.renderedD2Count !== result.fullScroll.d2Count)
|
||||
throw new Error('All d2 blocks should finish after full scroll settle.')
|
||||
if (!(result.replay.settleTimeMs <= 5000))
|
||||
throw new Error(`Replay settle should stay within 5000ms. Got ${result.replay.settleTimeMs}.`)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
let result = null
|
||||
const cleanup = () => killProcessTree(server.child)
|
||||
process.on('SIGINT', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
process.on('exit', cleanup)
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
const warmupContext = await browser.newContext({
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
baseURL: `http://${host}:${port}`,
|
||||
})
|
||||
const warmupPage = await warmupContext.newPage()
|
||||
await warmupPage.goto('/', { waitUntil: 'load' })
|
||||
await warmupPage.locator('.chatbot-container').waitFor({ state: 'visible', timeout: 15000 })
|
||||
await warmupContext.close()
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
baseURL: `http://${host}:${port}`,
|
||||
})
|
||||
await context.addInitScript(() => {
|
||||
const state = {
|
||||
startedAt: performance.now(),
|
||||
cls: 0,
|
||||
lcpMs: 0,
|
||||
lcpElement: null,
|
||||
longTasks: [],
|
||||
paints: {},
|
||||
layoutShifts: [],
|
||||
pauseEnabledSeen: false,
|
||||
}
|
||||
|
||||
window.__mainPlaygroundPerf = state
|
||||
|
||||
const describeElement = (element) => {
|
||||
if (!element)
|
||||
return null
|
||||
const tag = element.tagName ? element.tagName.toLowerCase() : 'unknown'
|
||||
const id = element.id ? `#${element.id}` : ''
|
||||
const className = typeof element.className === 'string'
|
||||
? element.className.trim().split(/\s+/).filter(Boolean).join('.')
|
||||
: ''
|
||||
return `${tag}${id}${className ? `.${className}` : ''}`
|
||||
}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.startTime > state.lcpMs) {
|
||||
state.lcpMs = entry.startTime
|
||||
state.lcpElement = describeElement(entry.element)
|
||||
}
|
||||
}
|
||||
}).observe({ type: 'largest-contentful-paint', buffered: true })
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!entry.hadRecentInput) {
|
||||
state.cls += entry.value
|
||||
state.layoutShifts.push({
|
||||
value: entry.value,
|
||||
sources: Array.isArray(entry.sources)
|
||||
? entry.sources
|
||||
.map(source => describeElement(source?.node))
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
}).observe({ type: 'layout-shift', buffered: true })
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries())
|
||||
state.longTasks.push(entry.duration)
|
||||
}).observe({ type: 'longtask', buffered: true })
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries())
|
||||
state.paints[entry.name] = entry.startTime
|
||||
}).observe({ type: 'paint', buffered: true })
|
||||
}
|
||||
catch {}
|
||||
})
|
||||
|
||||
const page = await context.newPage()
|
||||
result = await collectMetrics(page)
|
||||
assertScenario(result)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
await context.close()
|
||||
await browser.close()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[e2e-main-playground-performance] failed')
|
||||
console.error(error)
|
||||
if (result)
|
||||
console.error(JSON.stringify(result, null, 2))
|
||||
console.error(server.getLogs())
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/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-vue2-cli')
|
||||
const host = '127.0.0.1'
|
||||
|
||||
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 = 3338, end = 3368) {
|
||||
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 = 60000) {
|
||||
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 startDevServer(port) {
|
||||
const logBuffer = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['exec', 'vue-cli-service', 'serve', '--port', String(port), '--host', host],
|
||||
{
|
||||
cwd: playgroundDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none',
|
||||
CI: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
|
||||
const appendLogs = (chunk) => {
|
||||
const text = String(chunk)
|
||||
logBuffer.push(text)
|
||||
if (logBuffer.length > 120)
|
||||
logBuffer.splice(0, logBuffer.length - 120)
|
||||
}
|
||||
|
||||
child.stdout.on('data', appendLogs)
|
||||
child.stderr.on('data', appendLogs)
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logBuffer.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readProgress(page) {
|
||||
return page.evaluate(() => {
|
||||
const metaText = document.querySelector('.meta')?.textContent || ''
|
||||
const match = metaText.match(/\((\d+)%\)/)
|
||||
return match ? Number(match[1]) : null
|
||||
})
|
||||
}
|
||||
|
||||
async function scrollFirstIntoView(page, selector, timeout = 20000) {
|
||||
await page.waitForFunction((query) => {
|
||||
const el = document.querySelector(query)
|
||||
if (!el)
|
||||
return false
|
||||
el.scrollIntoView({ block: 'center', inline: 'nearest' })
|
||||
return true
|
||||
}, selector, { timeout })
|
||||
}
|
||||
|
||||
async function waitForRenderedMath(page) {
|
||||
await scrollFirstIntoView(page, '.math-block')
|
||||
await page.waitForFunction(() => Boolean(document.querySelector('.math-block .katex')), { timeout: 20000 })
|
||||
|
||||
await scrollFirstIntoView(page, '.math-inline-wrapper')
|
||||
await page.waitForFunction(() => Boolean(document.querySelector('.math-inline-wrapper .katex')), { timeout: 20000 })
|
||||
}
|
||||
|
||||
async function collectRegression(page) {
|
||||
await page.locator('#delay').fill('4')
|
||||
await page.locator('#chunk').fill('16')
|
||||
await page.getByRole('button', { name: 'Reset' }).click()
|
||||
|
||||
await waitForRenderedMath(page)
|
||||
const progressAtMonitorStart = await readProgress(page)
|
||||
|
||||
const seenRenderedBlocks = new Set()
|
||||
const seenRenderedInline = new Set()
|
||||
const regressedBlocks = new Set()
|
||||
const regressedInline = new Set()
|
||||
let maxVisibleBlockLoaders = 0
|
||||
let maxVisibleInlineLoaders = 0
|
||||
const uniqueProgress = new Set(progressAtMonitorStart == null ? [] : [progressAtMonitorStart])
|
||||
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < 25000) {
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const isVisible = (el) => {
|
||||
if (!el)
|
||||
return false
|
||||
const style = window.getComputedStyle(el)
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity || '1') > 0.05
|
||||
}
|
||||
|
||||
const blocks = Array.from(document.querySelectorAll('.math-block')).map((block, index) => ({
|
||||
index,
|
||||
hasKatex: Boolean(block.querySelector('.katex')),
|
||||
loadingVisible: isVisible(block.querySelector('.math-loading-overlay')),
|
||||
fallbackVisible: isVisible(block.querySelector('.math-block__fallback')),
|
||||
}))
|
||||
|
||||
const inline = Array.from(document.querySelectorAll('.math-inline-wrapper')).map((item, index) => ({
|
||||
index,
|
||||
hasKatex: Boolean(item.querySelector('.katex')),
|
||||
loadingVisible: isVisible(item.querySelector('.math-inline__loading')),
|
||||
fallbackVisible: isVisible(item.querySelector('.math-inline--fallback')),
|
||||
}))
|
||||
|
||||
const metaText = document.querySelector('.meta')?.textContent || ''
|
||||
const match = metaText.match(/\((\d+)%\)/)
|
||||
|
||||
return {
|
||||
blocks,
|
||||
inline,
|
||||
progress: match ? Number(match[1]) : null,
|
||||
}
|
||||
})
|
||||
|
||||
if (snapshot.progress != null)
|
||||
uniqueProgress.add(snapshot.progress)
|
||||
|
||||
let visibleBlockLoaders = 0
|
||||
for (const block of snapshot.blocks) {
|
||||
if (block.hasKatex)
|
||||
seenRenderedBlocks.add(block.index)
|
||||
if (block.loadingVisible)
|
||||
visibleBlockLoaders += 1
|
||||
if (seenRenderedBlocks.has(block.index) && (block.loadingVisible || block.fallbackVisible))
|
||||
regressedBlocks.add(block.index)
|
||||
}
|
||||
maxVisibleBlockLoaders = Math.max(maxVisibleBlockLoaders, visibleBlockLoaders)
|
||||
|
||||
let visibleInlineLoaders = 0
|
||||
for (const item of snapshot.inline) {
|
||||
if (item.hasKatex)
|
||||
seenRenderedInline.add(item.index)
|
||||
if (item.loadingVisible)
|
||||
visibleInlineLoaders += 1
|
||||
if (seenRenderedInline.has(item.index) && (item.loadingVisible || item.fallbackVisible))
|
||||
regressedInline.add(item.index)
|
||||
}
|
||||
maxVisibleInlineLoaders = Math.max(maxVisibleInlineLoaders, visibleInlineLoaders)
|
||||
|
||||
if (snapshot.progress === 100)
|
||||
break
|
||||
|
||||
await page.waitForTimeout(120)
|
||||
}
|
||||
|
||||
const finalProgress = Math.max(...Array.from(uniqueProgress.values()), 0)
|
||||
|
||||
return {
|
||||
progressAtMonitorStart,
|
||||
finalProgress,
|
||||
seenRenderedBlocks: Array.from(seenRenderedBlocks),
|
||||
seenRenderedInline: Array.from(seenRenderedInline),
|
||||
regressedBlocks: Array.from(regressedBlocks),
|
||||
regressedInline: Array.from(regressedInline),
|
||||
uniqueProgress: Array.from(uniqueProgress).sort((a, b) => a - b),
|
||||
maxVisibleBlockLoaders,
|
||||
maxVisibleInlineLoaders,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1600 } })
|
||||
const consoleErrors = []
|
||||
const pageErrors = []
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error')
|
||||
consoleErrors.push(msg.text())
|
||||
})
|
||||
page.on('pageerror', error => pageErrors.push(String(error)))
|
||||
|
||||
const url = `http://${host}:${port}/?probe=1`
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForSelector('#delay', { timeout: 30000 })
|
||||
await page.waitForSelector('.meta', { timeout: 30000 })
|
||||
|
||||
const regression = await collectRegression(page)
|
||||
const screenshot = '/tmp/vue2-cli-math-streaming-stability.png'
|
||||
await page.screenshot({ path: screenshot, fullPage: true })
|
||||
await browser.close()
|
||||
|
||||
const result = {
|
||||
url,
|
||||
...regression,
|
||||
consoleErrorCount: consoleErrors.length,
|
||||
pageErrorCount: pageErrors.length,
|
||||
screenshot,
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
|
||||
if (result.regressedBlocks.length > 0)
|
||||
throw new Error(`Math block regressed back to loading/fallback: ${result.regressedBlocks.join(', ')}`)
|
||||
if (result.regressedInline.length > 0)
|
||||
throw new Error(`Math inline regressed back to loading/fallback: ${result.regressedInline.join(', ')}`)
|
||||
if (result.seenRenderedBlocks.length === 0)
|
||||
throw new Error('No rendered math block was observed during the run')
|
||||
if (result.seenRenderedInline.length === 0)
|
||||
throw new Error('No rendered math inline node was observed during the run')
|
||||
if (result.finalProgress !== 100)
|
||||
throw new Error(`Streaming did not finish while monitoring math nodes (ended at ${result.finalProgress}%)`)
|
||||
if (result.consoleErrorCount > 0 || result.pageErrorCount > 0)
|
||||
throw new Error(`Unexpected browser errors: console=${result.consoleErrorCount}, page=${result.pageErrorCount}`)
|
||||
}
|
||||
catch (error) {
|
||||
const recentLogs = server.getLogs().trim()
|
||||
if (recentLogs) {
|
||||
console.error('--- vue-cli recent logs ---')
|
||||
console.error(recentLogs)
|
||||
console.error('--- end vue-cli recent logs ---')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
killProcessTree(server.child)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,473 @@
|
||||
#!/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
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
#!/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-nuxt')
|
||||
const host = '127.0.0.1'
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
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 = 4300, end = 4360) {
|
||||
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, timeoutMs = 90000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isPortOpen(port))
|
||||
return
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${host}:${port}`)
|
||||
}
|
||||
|
||||
async function waitForHttpReady(url, timeoutMs = 90000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok)
|
||||
return
|
||||
}
|
||||
catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
throw new Error(`Timed out waiting for HTTP readiness: ${url}`)
|
||||
}
|
||||
|
||||
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 createProcessRunner(command, args, cwd, extraEnv = {}) {
|
||||
const logBuffer = []
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NUXT_TELEMETRY_DISABLED: '1',
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
const appendLogs = (chunk) => {
|
||||
const text = String(chunk)
|
||||
logBuffer.push(text)
|
||||
if (logBuffer.length > 240)
|
||||
logBuffer.splice(0, logBuffer.length - 240)
|
||||
}
|
||||
|
||||
child.stdout.on('data', appendLogs)
|
||||
child.stderr.on('data', appendLogs)
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logBuffer.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(command, args, cwd, label) {
|
||||
const runner = createProcessRunner(command, args, cwd)
|
||||
const exitCode = await new Promise((resolve, reject) => {
|
||||
runner.child.on('error', reject)
|
||||
runner.child.on('exit', resolve)
|
||||
})
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`${label} failed with exit code ${exitCode}\n${runner.getLogs()}`)
|
||||
}
|
||||
}
|
||||
|
||||
function startNuxtDev(port) {
|
||||
return createProcessRunner('pnpm', ['exec', 'nuxt', 'dev', '--host', host, '--port', String(port)], playgroundDir)
|
||||
}
|
||||
|
||||
function startNuxtPreview(port) {
|
||||
return createProcessRunner(
|
||||
'pnpm',
|
||||
['exec', 'nuxt', 'preview'],
|
||||
playgroundDir,
|
||||
{
|
||||
HOST: host,
|
||||
PORT: String(port),
|
||||
NITRO_HOST: host,
|
||||
NITRO_PORT: String(port),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function assertRawHtml(baseUrl, mode) {
|
||||
const response = await fetch(`${baseUrl}/ssr-lab`)
|
||||
assert(response.ok, `[${mode}] raw SSR fetch failed with status ${response.status}`)
|
||||
const html = await response.text()
|
||||
|
||||
const expectedSnippets = [
|
||||
'data-ssr-case="basic"',
|
||||
'data-ssr-case="enhanced"',
|
||||
'data-ssr-case="fallback"',
|
||||
'SSR Basic Coverage',
|
||||
'data-ssr-inline-html="1"',
|
||||
'data-ssr-html-block="1"',
|
||||
'data-markstream-code-block="1"',
|
||||
'data-markstream-pre="1"',
|
||||
'data-markstream-math="inline"',
|
||||
'data-markstream-mode="katex"',
|
||||
'data-markstream-mermaid="1"',
|
||||
'data-markstream-d2="1"',
|
||||
'data-markstream-infographic="1"',
|
||||
'data-ssr-fallback="math-inline"',
|
||||
'data-ssr-fallback="mermaid"',
|
||||
]
|
||||
|
||||
for (const snippet of expectedSnippets)
|
||||
assert(html.includes(snippet), `[${mode}] raw SSR HTML is missing expected snippet: ${snippet}`)
|
||||
}
|
||||
|
||||
function collectUnexpectedIssues(entries) {
|
||||
const patterns = [
|
||||
/hydration/i,
|
||||
/window is not defined/i,
|
||||
/referenceerror/i,
|
||||
/cannot read properties/i,
|
||||
/failed to initialize mermaid renderer/i,
|
||||
/failed to render/i,
|
||||
]
|
||||
|
||||
return entries.filter(({ type, text }) => {
|
||||
if (type === 'pageerror')
|
||||
return String(text || '').trim() !== 'Event'
|
||||
if (/Could not create web worker\(s\)\./.test(text))
|
||||
return false
|
||||
return patterns.some(pattern => pattern.test(text))
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForEnhancedWidgets(page) {
|
||||
await page.locator('[data-ssr-case="enhanced"]').scrollIntoViewIfNeeded()
|
||||
await page.waitForSelector('[data-ssr-case="hydration"][data-ssr-hydrated="true"]', { timeout: 90000 })
|
||||
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-code-block="1"][data-markstream-enhanced="true"]', { timeout: 90000 })
|
||||
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-math="inline"][data-markstream-mode="katex"] .katex', { timeout: 90000 })
|
||||
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-math="block"][data-markstream-mode="katex"] .katex-display', { timeout: 90000 })
|
||||
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-mermaid="1"][data-markstream-mode="preview"] ._mermaid svg', { timeout: 90000 })
|
||||
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-d2="1"][data-markstream-mode="preview"] svg.markstream-d2-root-svg', { timeout: 90000 })
|
||||
await page.waitForSelector('[data-ssr-case="enhanced"] [data-markstream-infographic="1"][data-markstream-mode="preview"] svg', { timeout: 90000 })
|
||||
await page.waitForFunction(() => {
|
||||
const readyCount = document.querySelector('[data-ssr-status="ready-count"]')?.textContent?.trim()
|
||||
return readyCount === '5'
|
||||
}, { timeout: 90000 })
|
||||
}
|
||||
|
||||
async function assertFallbackSection(page, mode) {
|
||||
const fallbackState = await page.evaluate(() => {
|
||||
const section = document.querySelector('[data-ssr-case="fallback"]')
|
||||
return {
|
||||
mathInline: !!section?.querySelector('[data-ssr-fallback="math-inline"]'),
|
||||
mathBlock: !!section?.querySelector('[data-ssr-fallback="math-block"]'),
|
||||
mermaid: !!section?.querySelector('[data-ssr-fallback="mermaid"]'),
|
||||
d2: !!section?.querySelector('[data-ssr-fallback="d2"]'),
|
||||
infographic: !!section?.querySelector('[data-ssr-fallback="infographic"]'),
|
||||
previewLeak: !!section?.querySelector('[data-markstream-mode="preview"]'),
|
||||
}
|
||||
})
|
||||
|
||||
assert(fallbackState.mathInline, `[${mode}] fallback section lost inline math fallback`)
|
||||
assert(fallbackState.mathBlock, `[${mode}] fallback section lost block math fallback`)
|
||||
assert(fallbackState.mermaid, `[${mode}] fallback section lost mermaid fallback`)
|
||||
assert(fallbackState.d2, `[${mode}] fallback section lost d2 fallback`)
|
||||
assert(fallbackState.infographic, `[${mode}] fallback section lost infographic fallback`)
|
||||
assert(!fallbackState.previewLeak, `[${mode}] fallback section unexpectedly switched to preview mode`)
|
||||
}
|
||||
|
||||
async function assertBrowserBehavior(baseUrl, mode) {
|
||||
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
const page = await browser.newPage()
|
||||
const issues = []
|
||||
|
||||
page.on('console', (msg) => {
|
||||
const type = msg.type()
|
||||
if (type === 'error' || type === 'warning')
|
||||
issues.push({ type, text: msg.text() })
|
||||
})
|
||||
page.on('pageerror', (error) => {
|
||||
issues.push({ type: 'pageerror', text: String(error?.message ?? error) })
|
||||
})
|
||||
|
||||
try {
|
||||
await page.goto(`${baseUrl}/ssr-lab`, { waitUntil: 'domcontentloaded', timeout: 90000 })
|
||||
await waitForEnhancedWidgets(page, mode)
|
||||
await assertFallbackSection(page, mode)
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 })
|
||||
await waitForEnhancedWidgets(page, `${mode} reload`)
|
||||
await assertFallbackSection(page, `${mode} reload`)
|
||||
|
||||
await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded', timeout: 90000 })
|
||||
await page.goto(`${baseUrl}/ssr-lab`, { waitUntil: 'domcontentloaded', timeout: 90000 })
|
||||
await waitForEnhancedWidgets(page, `${mode} route`)
|
||||
await assertFallbackSection(page, `${mode} route`)
|
||||
}
|
||||
finally {
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
const unexpectedIssues = collectUnexpectedIssues(issues)
|
||||
assert(unexpectedIssues.length === 0, `[${mode}] browser reported unexpected issues:\n${unexpectedIssues.map(issue => `${issue.type}: ${issue.text}`).join('\n')}`)
|
||||
}
|
||||
|
||||
async function runDevMode() {
|
||||
const port = await findFreePort(4300, 4330)
|
||||
const runner = startNuxtDev(port)
|
||||
const baseUrl = `http://${host}:${port}`
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
await waitForHttpReady(`${baseUrl}/ssr-lab`)
|
||||
await assertRawHtml(baseUrl, 'nuxt dev')
|
||||
await assertBrowserBehavior(baseUrl, 'nuxt dev')
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`${error.message}\n\nNuxt dev logs:\n${runner.getLogs()}`)
|
||||
}
|
||||
finally {
|
||||
killProcessTree(runner.child)
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreviewMode() {
|
||||
await runCommand('pnpm', ['exec', 'nuxt', 'build'], playgroundDir, 'Nuxt build')
|
||||
|
||||
const port = await findFreePort(4331, 4360)
|
||||
const runner = startNuxtPreview(port)
|
||||
const baseUrl = `http://${host}:${port}`
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
await waitForHttpReady(`${baseUrl}/ssr-lab`)
|
||||
await assertRawHtml(baseUrl, 'nuxt preview')
|
||||
await assertBrowserBehavior(baseUrl, 'nuxt preview')
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`${error.message}\n\nNuxt preview logs:\n${runner.getLogs()}`)
|
||||
}
|
||||
finally {
|
||||
killProcessTree(runner.child)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await runCommand('pnpm', ['build'], repoRoot, 'Library build')
|
||||
await runDevMode()
|
||||
await runPreviewMode()
|
||||
console.log('Nuxt SSR e2e passed in dev and preview modes.')
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,446 @@
|
||||
#!/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 = 4250, end = 4290) {
|
||||
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, timeoutMs = 60000) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
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 startDevServer(port) {
|
||||
const logs = []
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['-C', playgroundDir, 'exec', 'vite', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
child.stdout.on('data', chunk => logs.push(String(chunk)))
|
||||
child.stderr.on('data', chunk => logs.push(String(chunk)))
|
||||
|
||||
return {
|
||||
child,
|
||||
getLogs() {
|
||||
return logs.join('')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForVisibleBlocksReady(page, rootSelector) {
|
||||
await page.waitForFunction((selector) => {
|
||||
const root = document.querySelector(selector)
|
||||
if (!root)
|
||||
return false
|
||||
const rootRect = root.getBoundingClientRect()
|
||||
const isVisible = (element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
|
||||
}
|
||||
const visibleCodeBlocks = Array.from(document.querySelectorAll('.code-block-container')).filter(isVisible)
|
||||
const visibleMermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]')).filter(isVisible)
|
||||
const visibleInfographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]')).filter(isVisible)
|
||||
return visibleCodeBlocks.every(element => !element.querySelector('.code-fallback-plain, .code-pre-fallback'))
|
||||
&& visibleMermaids.every(element => Boolean(element.querySelector('svg')))
|
||||
&& visibleInfographics.every(element => Boolean(element.querySelector('svg')))
|
||||
}, rootSelector, { timeout: 30000 })
|
||||
}
|
||||
|
||||
async function waitForAllD2Ready(page, timeout = 15000) {
|
||||
await page.waitForFunction(() => {
|
||||
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
|
||||
return d2Blocks.every(element => Boolean(element.querySelector('.d2-svg svg')))
|
||||
}, null, { timeout })
|
||||
}
|
||||
|
||||
async function scrollThroughRoot(page, rootSelector) {
|
||||
while (true) {
|
||||
await waitForVisibleBlocksReady(page, rootSelector)
|
||||
const state = await page.evaluate((selector) => {
|
||||
const root = document.querySelector(selector)
|
||||
if (!root)
|
||||
return { done: true }
|
||||
root.style.scrollBehavior = 'auto'
|
||||
const maxScrollTop = Math.max(0, root.scrollHeight - root.clientHeight)
|
||||
const step = Math.max(320, Math.round(root.clientHeight * 0.85))
|
||||
const nextScrollTop = Math.min(maxScrollTop, root.scrollTop + step)
|
||||
const done = nextScrollTop <= root.scrollTop + 1
|
||||
if (!done)
|
||||
root.scrollTop = nextScrollTop
|
||||
return { done }
|
||||
}, rootSelector)
|
||||
if (state.done)
|
||||
break
|
||||
await page.waitForTimeout(180)
|
||||
}
|
||||
await waitForVisibleBlocksReady(page, rootSelector)
|
||||
}
|
||||
|
||||
async function runScenario(browser, port, mode) {
|
||||
const rootSelector = '.preview-surface'
|
||||
const sample = process.env.PLAYGROUND_SAMPLE || 'baseline'
|
||||
const warmupContext = await browser.newContext({
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: `http://${host}:${port}`,
|
||||
localStorage: [
|
||||
{ name: 'vmr-test-sample', value: sample },
|
||||
{ name: 'vmr-test-render-mode', value: mode },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const warmupPage = await warmupContext.newPage()
|
||||
await warmupPage.goto(`http://${host}:${port}/test`, { waitUntil: 'load' })
|
||||
await warmupPage.locator('.workspace-card--preview').waitFor({ state: 'visible', timeout: 15000 })
|
||||
await warmupContext.close()
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1600, height: 1200 },
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [
|
||||
{
|
||||
origin: `http://${host}:${port}`,
|
||||
localStorage: [
|
||||
{ name: 'vmr-test-sample', value: sample },
|
||||
{ name: 'vmr-test-render-mode', value: mode },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
await context.addInitScript(() => {
|
||||
const state = {
|
||||
startedAt: performance.now(),
|
||||
cls: 0,
|
||||
lcpMs: 0,
|
||||
lcpElement: null,
|
||||
longTasks: [],
|
||||
paints: {},
|
||||
layoutShifts: [],
|
||||
}
|
||||
|
||||
window.__playgroundPerfState = state
|
||||
|
||||
const describeElement = (element) => {
|
||||
if (!element)
|
||||
return null
|
||||
const tag = element.tagName ? element.tagName.toLowerCase() : 'unknown'
|
||||
const id = element.id ? `#${element.id}` : ''
|
||||
const className = typeof element.className === 'string'
|
||||
? element.className.trim().split(/\s+/).filter(Boolean).join('.')
|
||||
: ''
|
||||
return `${tag}${id}${className ? `.${className}` : ''}`
|
||||
}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.startTime > state.lcpMs) {
|
||||
state.lcpMs = entry.startTime
|
||||
state.lcpElement = describeElement(entry.element)
|
||||
}
|
||||
}
|
||||
}).observe({ type: 'largest-contentful-paint', buffered: true })
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!entry.hadRecentInput) {
|
||||
state.cls += entry.value
|
||||
state.layoutShifts.push({
|
||||
value: entry.value,
|
||||
sources: Array.isArray(entry.sources)
|
||||
? entry.sources
|
||||
.map(source => describeElement(source?.node))
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
}).observe({ type: 'layout-shift', buffered: true })
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries())
|
||||
state.longTasks.push(entry.duration)
|
||||
}).observe({ type: 'longtask', buffered: true })
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries())
|
||||
state.paints[entry.name] = entry.startTime
|
||||
}).observe({ type: 'paint', buffered: true })
|
||||
}
|
||||
catch {}
|
||||
})
|
||||
|
||||
const page = await context.newPage()
|
||||
await page.goto(`http://${host}:${port}/test`, { waitUntil: 'load' })
|
||||
|
||||
await page.locator('.workspace-card--preview').waitFor({ state: 'visible', timeout: 15000 })
|
||||
await waitForVisibleBlocksReady(page, rootSelector)
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const state = window.__playgroundPerfState ?? {}
|
||||
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
|
||||
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
|
||||
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
|
||||
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
|
||||
const root = document.querySelector('.preview-surface')
|
||||
const rootRect = root?.getBoundingClientRect()
|
||||
const isVisible = (element) => {
|
||||
if (!rootRect)
|
||||
return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > rootRect.top && rect.top < rootRect.bottom
|
||||
}
|
||||
const visibleMermaids = mermaids.filter(isVisible)
|
||||
const visibleInfographics = infographics.filter(isVisible)
|
||||
const visibleD2Blocks = d2Blocks.filter(isVisible)
|
||||
const allCodeBlocks = Array.from(document.querySelectorAll('.code-block-container'))
|
||||
const visibleCodeBlocks = allCodeBlocks.filter(isVisible)
|
||||
const diffCodeBlocks = allCodeBlocks.filter(element =>
|
||||
element.classList.contains('is-diff') || Boolean(element.querySelector('.monaco-diff-editor')),
|
||||
)
|
||||
const visibleDiffCodeBlocks = diffCodeBlocks.filter(isVisible)
|
||||
const renderedMermaidCount = mermaids.filter(element => element.querySelector('svg')).length
|
||||
return {
|
||||
sample: localStorage.getItem('vmr-test-sample') ?? 'unknown',
|
||||
mode: localStorage.getItem('vmr-test-render-mode') ?? 'unknown',
|
||||
lcpMs: Number(state.lcpMs ?? 0),
|
||||
lcpElement: state.lcpElement ?? null,
|
||||
cls: Number(state.cls ?? 0),
|
||||
firstPaintMs: Number(state.paints?.paint ?? 0),
|
||||
firstContentfulPaintMs: Number(state.paints?.['first-contentful-paint'] ?? 0),
|
||||
longTaskCount: longTasks.length,
|
||||
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
|
||||
longTaskMaxMs: longTasks.length ? Math.max(...longTasks) : 0,
|
||||
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
|
||||
codeBlockCount: allCodeBlocks.length,
|
||||
diffCodeBlockCount: diffCodeBlocks.length,
|
||||
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
|
||||
visibleCodeBlockCount: visibleCodeBlocks.length,
|
||||
visibleFallbackCount: visibleCodeBlocks.filter(element => element.querySelector('.code-fallback-plain, .code-pre-fallback')).length,
|
||||
visibleDiffCodeBlockCount: visibleDiffCodeBlocks.length,
|
||||
visibleDiffFallbackCount: visibleDiffCodeBlocks.filter(element => element.querySelector('.code-fallback-plain, .code-pre-fallback')).length,
|
||||
mermaidCount: mermaids.length,
|
||||
renderedMermaidCount,
|
||||
visibleMermaidCount: visibleMermaids.length,
|
||||
visibleRenderedMermaidCount: visibleMermaids.filter(element => element.querySelector('svg')).length,
|
||||
infographicCount: infographics.length,
|
||||
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
|
||||
visibleInfographicCount: visibleInfographics.length,
|
||||
visibleRenderedInfographicCount: visibleInfographics.filter(element => element.querySelector('svg')).length,
|
||||
d2Count: d2Blocks.length,
|
||||
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
|
||||
visibleD2Count: visibleD2Blocks.length,
|
||||
visibleRenderedD2Count: visibleD2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
|
||||
sandboxFrameMounted: Boolean(document.querySelector('.sandbox-frame')),
|
||||
topLayoutShifts: Array.isArray(state.layoutShifts)
|
||||
? [...state.layoutShifts]
|
||||
.sort((a, b) => Number(b?.value || 0) - Number(a?.value || 0))
|
||||
.slice(0, 5)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
|
||||
await scrollThroughRoot(page, rootSelector)
|
||||
await waitForAllD2Ready(page)
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
result.fullScroll = await page.evaluate(() => {
|
||||
const state = window.__playgroundPerfState ?? {}
|
||||
const longTasks = Array.isArray(state.longTasks) ? state.longTasks : []
|
||||
const mermaids = Array.from(document.querySelectorAll('[data-markstream-mermaid="1"]'))
|
||||
const infographics = Array.from(document.querySelectorAll('[data-markstream-infographic="1"]'))
|
||||
const d2Blocks = Array.from(document.querySelectorAll('[data-markstream-d2="1"]'))
|
||||
return {
|
||||
settleTimeMs: performance.now() - Number(state.startedAt ?? 0),
|
||||
fallbackCount: document.querySelectorAll('.code-fallback-plain, .code-pre-fallback').length,
|
||||
mermaidCount: mermaids.length,
|
||||
renderedMermaidCount: mermaids.filter(element => element.querySelector('svg')).length,
|
||||
infographicCount: infographics.length,
|
||||
renderedInfographicCount: infographics.filter(element => element.querySelector('svg')).length,
|
||||
d2Count: d2Blocks.length,
|
||||
renderedD2Count: d2Blocks.filter(element => element.querySelector('.d2-svg svg')).length,
|
||||
longTaskTotalMs: longTasks.reduce((sum, duration) => sum + Number(duration || 0), 0),
|
||||
}
|
||||
})
|
||||
|
||||
await context.close()
|
||||
return result
|
||||
}
|
||||
|
||||
function assertScenario(result) {
|
||||
if (!(result.codeBlockCount > 0))
|
||||
throw new Error(`[${result.mode}] Expected at least one rendered code block.`)
|
||||
if (result.visibleFallbackCount !== 0)
|
||||
throw new Error(`[${result.mode}] Visible code fallback should be gone after initial settle.`)
|
||||
if (result.sample === 'diff' && result.mode === 'monaco' && !(result.diffCodeBlockCount > 0))
|
||||
throw new Error('[monaco] Diff sample should render at least one Monaco diff block.')
|
||||
if (result.sample === 'diff' && result.mode === 'monaco' && result.visibleDiffFallbackCount !== 0)
|
||||
throw new Error('[monaco] Visible diff fallback should be gone after initial settle.')
|
||||
if (result.visibleRenderedMermaidCount !== result.visibleMermaidCount)
|
||||
throw new Error(`[${result.mode}] Visible mermaid blocks should finish in preview mode after initial settle.`)
|
||||
if (result.visibleRenderedInfographicCount !== result.visibleInfographicCount)
|
||||
throw new Error(`[${result.mode}] Visible infographic blocks should finish after initial settle.`)
|
||||
if (!(result.lcpMs > 0 && result.lcpMs <= 5000))
|
||||
throw new Error(`[${result.mode}] LCP should stay within 5000ms. Got ${result.lcpMs}.`)
|
||||
if (!(result.cls <= 0.05))
|
||||
throw new Error(`[${result.mode}] CLS should stay within 0.05. Got ${result.cls}.`)
|
||||
if (!(result.settleTimeMs <= 6000))
|
||||
throw new Error(`[${result.mode}] Settle time should stay within 6000ms. Got ${result.settleTimeMs}.`)
|
||||
if (!(result.longTaskMaxMs <= 700))
|
||||
throw new Error(`[${result.mode}] Max long task should stay within 700ms. Got ${result.longTaskMaxMs}.`)
|
||||
if (!(result.longTaskTotalMs <= 1800))
|
||||
throw new Error(`[${result.mode}] Total long task time should stay within 1800ms. Got ${result.longTaskTotalMs}.`)
|
||||
if (result.fullScroll.fallbackCount !== 0)
|
||||
throw new Error(`[${result.mode}] Code fallback should be gone after full scroll settle.`)
|
||||
if (result.fullScroll.renderedMermaidCount !== result.fullScroll.mermaidCount)
|
||||
throw new Error(`[${result.mode}] Mermaid blocks should all finish after full scroll settle.`)
|
||||
if (result.fullScroll.renderedInfographicCount !== result.fullScroll.infographicCount)
|
||||
throw new Error(`[${result.mode}] Infographic blocks should all finish after full scroll settle.`)
|
||||
if (result.fullScroll.renderedD2Count !== result.fullScroll.d2Count)
|
||||
throw new Error(`[${result.mode}] D2 blocks should all finish after full scroll settle.`)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
|
||||
const server = startDevServer(port)
|
||||
let results = null
|
||||
const cleanup = () => killProcessTree(server.child)
|
||||
process.on('SIGINT', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
process.on('exit', cleanup)
|
||||
|
||||
try {
|
||||
await waitForPort(port)
|
||||
const browser = await chromium.launch(resolveChromeLaunchOptions())
|
||||
|
||||
const markdownResult = await runScenario(browser, port, 'markdown')
|
||||
const monacoResult = await runScenario(browser, port, 'monaco')
|
||||
results = {
|
||||
markdown: markdownResult,
|
||||
monaco: monacoResult,
|
||||
}
|
||||
|
||||
assertScenario(markdownResult)
|
||||
assertScenario(monacoResult)
|
||||
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
|
||||
await browser.close()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[e2e-playground-performance] failed')
|
||||
console.error(error)
|
||||
if (results)
|
||||
console.error(JSON.stringify(results, null, 2))
|
||||
console.error(server.getLogs())
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
|
||||
const checks = [
|
||||
{
|
||||
name: 'codeblock-highlight-stability',
|
||||
script: path.join(repoRoot, 'scripts/e2e-codeblock-highlight-stability.mjs'),
|
||||
},
|
||||
{
|
||||
name: 'link-tooltip-streaming',
|
||||
script: path.join(repoRoot, 'scripts/e2e-link-tooltip-streaming.mjs'),
|
||||
},
|
||||
{
|
||||
name: 'math-streaming-stability',
|
||||
script: path.join(repoRoot, 'scripts/e2e-math-streaming-stability.mjs'),
|
||||
},
|
||||
]
|
||||
|
||||
function runCheck(check) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now()
|
||||
const child = spawn(process.execPath, [check.script], {
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('error', reject)
|
||||
child.on('exit', (code, signal) => {
|
||||
const durationMs = Date.now() - startedAt
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
...check,
|
||||
durationMs,
|
||||
ok: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
reject(new Error(`${check.name} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results = []
|
||||
|
||||
for (const check of checks) {
|
||||
console.log(`\n=== Running ${check.name} ===`)
|
||||
const result = await runCheck(check)
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
console.log('\n=== Vue2 CLI smoke summary ===')
|
||||
console.log(JSON.stringify({
|
||||
checks: results.map(result => ({
|
||||
name: result.name,
|
||||
durationMs: result.durationMs,
|
||||
ok: result.ok,
|
||||
})),
|
||||
}, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('\n=== Vue2 CLI smoke failed ===')
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const root = process.cwd()
|
||||
const dist = path.resolve(root, 'dist')
|
||||
const assetsDir = path.resolve(dist, 'assets')
|
||||
const indexFile = path.resolve(dist, 'index.js')
|
||||
|
||||
// Remove any hashed worker files that might be left in dist root or dist/assets
|
||||
function removeHashedWorkers() {
|
||||
const candidates = [dist, path.resolve(dist, 'assets')]
|
||||
for (const dir of candidates) {
|
||||
if (!fs.existsSync(dir))
|
||||
continue
|
||||
const files = fs.readdirSync(dir)
|
||||
for (const f of files) {
|
||||
if (/katexRenderer\.worker[-_.].*\.js$/.test(f) || /mermaidParser\.worker[-_.].*\.js$/.test(f)) {
|
||||
try {
|
||||
fs.unlinkSync(path.resolve(dir, f))
|
||||
console.log('Removed hashed worker file:', path.resolve(dir, f))
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to remove hashed worker file:', f, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeHashedWorkers()
|
||||
|
||||
// Now flatten remaining assets from dist/assets into dist root (if any)
|
||||
function flattenAssets() {
|
||||
if (!fs.existsSync(assetsDir))
|
||||
return
|
||||
const files = fs.readdirSync(assetsDir)
|
||||
for (const f of files) {
|
||||
// skip the worker files we've already handled
|
||||
if (/^katexRenderer\\.worker|^mermaidParser\\.worker/.test(f))
|
||||
continue
|
||||
const src = path.resolve(assetsDir, f)
|
||||
const dest = path.resolve(dist, f)
|
||||
// avoid overwriting existing file
|
||||
if (fs.existsSync(dest)) {
|
||||
console.log('Skipping move because destination exists:', dest)
|
||||
continue
|
||||
}
|
||||
fs.renameSync(src, dest)
|
||||
console.log('Moved asset', src, '->', dest)
|
||||
// patch index.js references from assets/<f> to <f>
|
||||
if (fs.existsSync(indexFile)) {
|
||||
let content = fs.readFileSync(indexFile, 'utf8')
|
||||
const oldRef = `assets/${f}`
|
||||
if (content.includes(oldRef)) {
|
||||
content = content.split(oldRef).join(f)
|
||||
fs.writeFileSync(indexFile, content, 'utf8')
|
||||
console.log('Patched index.js reference', oldRef, '->', f)
|
||||
}
|
||||
}
|
||||
}
|
||||
// remove assetsDir if empty
|
||||
try {
|
||||
const remaining = fs.readdirSync(assetsDir)
|
||||
if (remaining.length === 0)
|
||||
fs.rmdirSync(assetsDir)
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
flattenAssets()
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const sourceCss = path.resolve('dist/index.css')
|
||||
const targetCss = path.resolve('dist/index.px.css')
|
||||
const pxPerRem = 16
|
||||
const remPattern = /(-?(?:\d+(?:\.\d+)?|\.\d+))rem\b/g
|
||||
|
||||
function formatPx(value) {
|
||||
if (value === 0)
|
||||
return '0'
|
||||
|
||||
const rounded = Math.round(value * 10000) / 10000
|
||||
return `${Number.parseFloat(rounded.toFixed(4))}px`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let css = ''
|
||||
try {
|
||||
css = await fs.readFile(sourceCss, 'utf8')
|
||||
}
|
||||
catch (err) {
|
||||
const e = err
|
||||
throw new Error(`[generate-px-css] Missing ${sourceCss}. Did the npm build run? (${e?.message || e})`)
|
||||
}
|
||||
|
||||
let replacementCount = 0
|
||||
const convertedCss = css.replace(remPattern, (_, remValue) => {
|
||||
const rem = Number(remValue)
|
||||
if (Number.isNaN(rem))
|
||||
return `${remValue}rem`
|
||||
|
||||
replacementCount += 1
|
||||
return formatPx(rem * pxPerRem)
|
||||
})
|
||||
|
||||
await fs.writeFile(targetCss, convertedCss, 'utf8')
|
||||
console.log(`[generate-px-css] Wrote ${targetCss} (${replacementCount} rem values converted using 1rem=${pxPerRem}px)`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import process from 'node:process'
|
||||
import {
|
||||
parseInstallArgs,
|
||||
printInstallHelp,
|
||||
printInstallSummary,
|
||||
runSkillsInstall,
|
||||
} from '../bin/skills-lib.mjs'
|
||||
|
||||
async function main() {
|
||||
const args = parseInstallArgs(process.argv.slice(2), {
|
||||
target: 'agents',
|
||||
mode: 'symlink',
|
||||
})
|
||||
|
||||
if (args.help) {
|
||||
printInstallHelp('node scripts/install-skills.mjs', 'symlink')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await runSkillsInstall(args)
|
||||
printInstallSummary(result)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Measure KaTeX render time and recommend a worker threshold N
|
||||
* N ≈ B / (R × (1 - H))
|
||||
* - B: main-thread budget (ms) — defaults to 50ms and 16.7ms
|
||||
* - R: avg render time per unique formula (ms)
|
||||
* - H: cache hit rate (0~1). For first paint assume H=0
|
||||
*/
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import process from 'node:process'
|
||||
|
||||
async function getKatex() {
|
||||
try {
|
||||
const k = await import('katex')
|
||||
return k.default || k
|
||||
}
|
||||
catch {
|
||||
console.error('[measure-katex-threshold] KaTeX not installed. pnpm add katex -D')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const FORMULAS = {
|
||||
simple: [
|
||||
'x',
|
||||
'a + b',
|
||||
'E = mc^2',
|
||||
],
|
||||
medium: [
|
||||
'\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}',
|
||||
'\\frac{a}{b} + \\sqrt{x}',
|
||||
'\\lim_{x \\to 0} \\frac{\\sin x}{x}',
|
||||
],
|
||||
complex: [
|
||||
'\\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi}',
|
||||
'\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}',
|
||||
'\\frac{d}{dx}\\left(\\int_{a}^{x} f(t)\\,dt\\right) = f(x)',
|
||||
],
|
||||
}
|
||||
|
||||
function bench(katex, formulas, iterations = 50) {
|
||||
// warm-up
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (const f of formulas) katex.renderToString(f, { throwOnError: false, displayMode: true })
|
||||
}
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
for (const f of formulas) katex.renderToString(f, { throwOnError: false, displayMode: true })
|
||||
}
|
||||
const end = performance.now()
|
||||
const total = end - start
|
||||
const renders = iterations * formulas.length
|
||||
return { avg: total / renders, total, renders }
|
||||
}
|
||||
|
||||
function recommend({ R, H = 0, B = 50 }) {
|
||||
const denom = R * (1 - H) || 1e-6
|
||||
const N = B / denom
|
||||
return Math.max(1, Math.floor(N))
|
||||
}
|
||||
|
||||
function fmt(n, digits = 2) {
|
||||
return Number(n).toFixed(digits)
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const katex = await getKatex()
|
||||
|
||||
const s = bench(katex, FORMULAS.simple)
|
||||
const m = bench(katex, FORMULAS.medium)
|
||||
const c = bench(katex, FORMULAS.complex)
|
||||
|
||||
const rec50 = {
|
||||
simple: recommend({ R: s.avg, B: 50 }),
|
||||
medium: recommend({ R: m.avg, B: 50 }),
|
||||
complex: recommend({ R: c.avg, B: 50 }),
|
||||
}
|
||||
const rec16 = {
|
||||
simple: recommend({ R: s.avg, B: 16.7 }),
|
||||
medium: recommend({ R: m.avg, B: 16.7 }),
|
||||
complex: recommend({ R: c.avg, B: 16.7 }),
|
||||
}
|
||||
|
||||
console.log('\nKaTeX Rendering Benchmark (ms per render)')
|
||||
console.table({
|
||||
simple: fmt(s.avg),
|
||||
medium: fmt(m.avg),
|
||||
complex: fmt(c.avg),
|
||||
})
|
||||
|
||||
console.log('\nRecommended worker thresholds (N = floor(B / R))')
|
||||
console.table({
|
||||
'B=50ms (no cache)': rec50,
|
||||
'B=16.7ms (no cache)': rec16,
|
||||
})
|
||||
|
||||
console.log('\nNotes:')
|
||||
console.log('- Thresholds are for first paint (H=0). With cache, N increases roughly by 1/(1-H).')
|
||||
console.log('- Use medium thresholds as default. Drop to complex thresholds if many matrices/integrals.')
|
||||
console.log('- If your content is very simple, you can raise the threshold accordingly.')
|
||||
})()
|
||||
@@ -0,0 +1,23 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const process = require('node:process')
|
||||
|
||||
const distDir = path.resolve(__dirname, '..', 'dist')
|
||||
const sourcePath = path.join(distDir, 'types', 'src', 'exports.d.ts')
|
||||
const targetPath = path.join(distDir, 'types', 'exports.d.ts')
|
||||
|
||||
function rewriteEntryImports(content) {
|
||||
return content
|
||||
.replace(/from\s+(['"])\.\//g, 'from $1./src/')
|
||||
.replace(/import\((['"])\.\//g, 'import($1./src/')
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true })
|
||||
const sourceContent = fs.readFileSync(sourcePath, 'utf8')
|
||||
const rewrittenContent = rewriteEntryImports(sourceContent)
|
||||
fs.writeFileSync(targetPath, rewrittenContent)
|
||||
console.log('Prepared declaration entry', targetPath, 'from', sourcePath)
|
||||
@@ -0,0 +1,118 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import process from 'node:process'
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
|
||||
function runGit(args, options) {
|
||||
return run('git', args, options)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
pattern: 'v*',
|
||||
remote: 'origin',
|
||||
dryRun: true,
|
||||
deleteLocal: false,
|
||||
deleteRemote: false,
|
||||
}
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const cur = argv[i]
|
||||
if (cur === '--pattern') {
|
||||
args.pattern = argv[++i]
|
||||
}
|
||||
else if (cur === '--remote') {
|
||||
args.remote = argv[++i]
|
||||
}
|
||||
else if (cur === '--apply') {
|
||||
args.dryRun = false
|
||||
}
|
||||
else if (cur === '--dry-run') {
|
||||
args.dryRun = true
|
||||
}
|
||||
else if (cur === '--local') {
|
||||
args.deleteLocal = true
|
||||
}
|
||||
else if (cur === '--remote-delete') {
|
||||
args.deleteRemote = true
|
||||
}
|
||||
else if (cur === '--help' || cur === '-h') {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/prune-tags.mjs [options]
|
||||
|
||||
Options:
|
||||
--pattern <glob> Tag pattern for git tag --list (default: v*)
|
||||
--dry-run Print actions only (default)
|
||||
--apply Actually delete tags (requires --local and/or --remote-delete)
|
||||
--local Delete local tags
|
||||
--remote-delete Delete remote tags (git push origin :refs/tags/<tag>)
|
||||
--remote <name> Git remote name (default: origin)
|
||||
|
||||
Examples:
|
||||
node scripts/prune-tags.mjs --pattern 'v*' --dry-run
|
||||
node scripts/prune-tags.mjs --pattern 'v*' --apply --local
|
||||
node scripts/prune-tags.mjs --pattern 'v*' --apply --local --remote-delete --remote origin
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unknown argument: ${cur}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.dryRun && !args.deleteLocal && !args.deleteRemote)
|
||||
throw new Error('Nothing to do: pass --local and/or --remote-delete with --apply.')
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const tags = runGit(['tag', '--list', args.pattern])
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!tags.length) {
|
||||
console.log(`[prune-tags] No tags match: ${args.pattern}`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[prune-tags] matched=${tags.length} pattern=${args.pattern} dryRun=${args.dryRun}`)
|
||||
|
||||
for (const tag of tags) {
|
||||
if (args.deleteLocal) {
|
||||
const cmd = ['tag', '-d', tag]
|
||||
if (args.dryRun) {
|
||||
console.log(`[prune-tags][dry-run] git ${cmd.join(' ')}`)
|
||||
}
|
||||
else {
|
||||
runGit(cmd, { stdio: 'inherit' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.deleteRemote) {
|
||||
for (const tag of tags) {
|
||||
const cmd = ['push', args.remote, `:refs/tags/${tag}`]
|
||||
if (args.dryRun) {
|
||||
console.log(`[prune-tags][dry-run] git ${cmd.join(' ')}`)
|
||||
}
|
||||
else {
|
||||
runGit(cmd, { stdio: 'inherit' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import dts from 'rollup-plugin-dts'
|
||||
|
||||
// Bundle the generated declaration entry into a single dist/index.d.ts.
|
||||
// Treat workspace deps as external so we don't try to resolve them during
|
||||
// bundling (the consumer will have their own types for those packages).
|
||||
// If the expected Vite output entry (./dist/types/exports.d.ts) is missing
|
||||
// fall back to an existing `dist/index.d.ts` so the build doesn't fail in
|
||||
// environments where declarations were previously emitted differently.
|
||||
const fallbackInput = './dist/index.d.ts'
|
||||
const preferredInput = './dist/types/exports.d.ts'
|
||||
|
||||
const preferredExists = fs.existsSync(path.resolve(preferredInput))
|
||||
const fallbackExists = fs.existsSync(path.resolve(fallbackInput))
|
||||
|
||||
if (!preferredExists && !fallbackExists) {
|
||||
throw new Error(
|
||||
'No declaration entry found. Run `pnpm run build` (which emits declarations) or otherwise ensure one of `./dist/types/exports.d.ts` or `./dist/index.d.ts` exists before running `build:dts`.',
|
||||
)
|
||||
}
|
||||
|
||||
const inputPath = preferredExists ? preferredInput : fallbackInput
|
||||
|
||||
export default [
|
||||
{
|
||||
// Prefer the Vite emitted declarations under dist/types if present
|
||||
input: inputPath,
|
||||
plugins: [
|
||||
dts({
|
||||
// Inline local type imports to ensure named interfaces are bundled
|
||||
respectExternal: false,
|
||||
}),
|
||||
],
|
||||
external: [
|
||||
/^stream-markdown-parser(?:\/.*)?$/,
|
||||
],
|
||||
output: [
|
||||
{
|
||||
file: 'dist/index.d.ts',
|
||||
format: 'es',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const root = process.cwd()
|
||||
const enDir = path.join(root, 'docs', 'guide')
|
||||
const zhDir = path.join(root, 'docs', 'zh', 'guide')
|
||||
|
||||
async function ensureDir(d) {
|
||||
try {
|
||||
await fs.mkdir(d, { recursive: true })
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toZhFilename(file) {
|
||||
return file
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureDir(zhDir)
|
||||
const files = await fs.readdir(enDir)
|
||||
const mdFiles = files.filter(f => f.endsWith('.md'))
|
||||
|
||||
const created = []
|
||||
for (const f of mdFiles) {
|
||||
const enPath = path.join(enDir, f)
|
||||
const zhPath = path.join(zhDir, toZhFilename(f))
|
||||
// decide whether to create or refresh placeholder
|
||||
let shouldWrite = false
|
||||
try {
|
||||
await fs.access(zhPath)
|
||||
const cur = await fs.readFile(zhPath, 'utf8')
|
||||
if (/中文占位|自动生成占位/.test(cur)) {
|
||||
shouldWrite = true // refresh old placeholder with improved template
|
||||
}
|
||||
else {
|
||||
// translation exists; skip
|
||||
continue
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// file doesn't exist -> create placeholder
|
||||
shouldWrite = true
|
||||
}
|
||||
|
||||
if (!shouldWrite)
|
||||
continue
|
||||
const enContent = await fs.readFile(enPath, 'utf8')
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking
|
||||
const titleMatch = enContent.match(/^#\s+(.+)$/m)
|
||||
const title = titleMatch ? titleMatch[1].trim() : path.basename(f, '.md')
|
||||
|
||||
// include a short excerpt to help translators
|
||||
// Use a robust, non-regex approach to extract a short excerpt from the
|
||||
// section content to avoid regexp backtracking issues flagged by eslint.
|
||||
const startIdx = titleMatch ? enContent.indexOf(titleMatch[0]) + titleMatch[0].length : 0
|
||||
const excerptRaw = enContent.slice(startIdx).trim().split('\n').slice(0, 6).join(' ')
|
||||
const excerpt = excerptRaw.substring(0, 300).trim()
|
||||
|
||||
const placeholder = `# ${title}(中文占位)\n\n`
|
||||
+ `> 原文节选(仅供翻译参考):\n\n${
|
||||
excerpt ? `> ${excerpt.replace(/\n/g, '\n> ')}\n\n` : ''
|
||||
}## 说明\n\n此页面为中文占位,原文(English):/guide/${f}。\n\n`
|
||||
+ `- [ ] 翻译标题和正文\n`
|
||||
+ `- [ ] 本地化示例中的代码注释\n`
|
||||
+ `- [ ] 检查链接并改为 /zh/ 前缀(如有)\n`
|
||||
+ `\n`
|
||||
+ `如果你愿意贡献中文翻译,请参考翻译指南并提交 PR:/guide/translation\n\n`
|
||||
+ `---\n\n`
|
||||
+ `> 自动生成占位:将原文翻译并替换此文件的内容即可。`
|
||||
|
||||
await fs.writeFile(zhPath, placeholder, 'utf8')
|
||||
created.push(zhPath)
|
||||
}
|
||||
|
||||
if (created.length) {
|
||||
console.log('Created placeholders for missing zh docs:')
|
||||
created.forEach(f => console.log(' -', f))
|
||||
}
|
||||
else {
|
||||
console.log('All zh docs exist. Nothing to create.')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return execFileSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
|
||||
function runGit(args, options) {
|
||||
return run('git', args, options)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
packageJson: 'package.json',
|
||||
ref: 'HEAD',
|
||||
tag: null,
|
||||
message: null,
|
||||
remote: 'origin',
|
||||
push: false,
|
||||
pushTagOnly: false,
|
||||
allowDirty: false,
|
||||
dryRun: false,
|
||||
force: false,
|
||||
}
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const cur = argv[i]
|
||||
if (cur === '--package-json') {
|
||||
args.packageJson = argv[++i]
|
||||
}
|
||||
else if (cur === '--ref') {
|
||||
args.ref = argv[++i]
|
||||
}
|
||||
else if (cur === '--tag') {
|
||||
args.tag = argv[++i]
|
||||
}
|
||||
else if (cur === '--message') {
|
||||
args.message = argv[++i]
|
||||
}
|
||||
else if (cur === '--remote') {
|
||||
args.remote = argv[++i]
|
||||
}
|
||||
else if (cur === '--push') {
|
||||
args.push = true
|
||||
}
|
||||
else if (cur === '--push-tag-only') {
|
||||
args.pushTagOnly = true
|
||||
}
|
||||
else if (cur === '--allow-dirty') {
|
||||
args.allowDirty = true
|
||||
}
|
||||
else if (cur === '--dry-run') {
|
||||
args.dryRun = true
|
||||
}
|
||||
else if (cur === '--force') {
|
||||
args.force = true
|
||||
}
|
||||
else if (cur === '--help' || cur === '-h') {
|
||||
console.log(`
|
||||
Usage:
|
||||
node scripts/tag-package.mjs [options]
|
||||
|
||||
Options:
|
||||
--package-json <path> Path to package.json (default: package.json)
|
||||
--ref <git-ref> Tag target ref (default: HEAD)
|
||||
--tag <tagName> Override tag name (default: <name>@<version>)
|
||||
--message <msg> Tag annotation message (default: tag name)
|
||||
--remote <name> Git remote to push to (default: origin)
|
||||
--push Push current branch + the new tag
|
||||
--push-tag-only Only push the tag (skip git push)
|
||||
--allow-dirty Skip clean working tree check
|
||||
--dry-run Print actions without mutating git state
|
||||
--force Force-update local tag if it exists
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
else {
|
||||
throw new Error(`Unknown argument: ${cur}`)
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function getCommitForRef(ref) {
|
||||
return runGit(['rev-parse', `${ref}^{}`]).trim()
|
||||
}
|
||||
|
||||
function getCommitForTag(tag) {
|
||||
try {
|
||||
return runGit(['rev-parse', `${tag}^{}`]).trim()
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (!args.allowDirty) {
|
||||
const dirty = runGit(['status', '--porcelain']).trim()
|
||||
if (dirty) {
|
||||
throw new Error(`Working tree is not clean. Commit/stash changes or pass --allow-dirty.\n\n${dirty}`)
|
||||
}
|
||||
}
|
||||
|
||||
const packageJsonAbs = path.resolve(process.cwd(), args.packageJson)
|
||||
const pkg = JSON.parse(await readFile(packageJsonAbs, 'utf8'))
|
||||
const name = String(pkg?.name ?? '').trim()
|
||||
const version = String(pkg?.version ?? '').trim()
|
||||
if (!name || !version)
|
||||
throw new Error(`Invalid package.json (missing name/version): ${args.packageJson}`)
|
||||
|
||||
const tagName = args.tag ?? `${name}@${version}`
|
||||
const message = args.message ?? tagName
|
||||
const refCommit = getCommitForRef(args.ref)
|
||||
const existingCommit = getCommitForTag(tagName)
|
||||
|
||||
if (existingCommit && existingCommit === refCommit) {
|
||||
console.log(`[tag-package] Tag already exists: ${tagName} -> ${existingCommit}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (existingCommit && !args.force)
|
||||
throw new Error(`[tag-package] Tag already exists and points elsewhere: ${tagName} -> ${existingCommit} (wanted ${refCommit}). Use --force if you really want to retag locally.`)
|
||||
|
||||
const tagArgs = ['tag', '-a', tagName, args.ref, '-m', message]
|
||||
if (args.force)
|
||||
tagArgs.splice(2, 0, '-f')
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(`[tag-package][dry-run] git ${tagArgs.join(' ')}`)
|
||||
}
|
||||
else {
|
||||
runGit(tagArgs, { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
if (!args.push)
|
||||
return
|
||||
|
||||
if (!args.pushTagOnly) {
|
||||
if (args.dryRun) {
|
||||
console.log('[tag-package][dry-run] git push')
|
||||
}
|
||||
else {
|
||||
runGit(['push'], { stdio: 'inherit' })
|
||||
}
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(`[tag-package][dry-run] git push ${args.remote} ${tagName}`)
|
||||
}
|
||||
else {
|
||||
runGit(['push', args.remote, tagName], { stdio: 'inherit' })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.message || err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Theme generation CLI.
|
||||
*
|
||||
* Usage:
|
||||
* # Generate from key colors (hex)
|
||||
* node scripts/theme-gen/cli.mjs generate \
|
||||
* --name claude \
|
||||
* --bg "#f5f4ed" --fg "#141413" --brand "#c96442" \
|
||||
* [--surface "#faf9f5"] [--border "#e8e6dc"] [--link "#0366d6"]
|
||||
*
|
||||
* # Convert a single color
|
||||
* node scripts/theme-gen/cli.mjs convert "#c96442"
|
||||
*
|
||||
* # Validate contrast of a generated theme
|
||||
* node scripts/theme-gen/cli.mjs validate --file themes/claude.css
|
||||
*
|
||||
* # Demo: generate themes for several well-known DESIGN.md palettes
|
||||
* node scripts/theme-gen/cli.mjs demo
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { hslObjToHex, toHsl, toOklch, toOklchCss, toShadcnHsl } from './color.mjs'
|
||||
import { generateBothSchemes, validateContrast } from './dark.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** Token keys that are not color values (font stacks, radius, etc.) — rendered in the shared block */
|
||||
const NON_COLOR_TOKENS = new Set(['font-sans', 'font-mono', 'font-serif', 'radius'])
|
||||
|
||||
// ─── CLI dispatch ───────────────────────────────────────────────────
|
||||
|
||||
const [,, command, ...args] = process.argv
|
||||
|
||||
const commands = { generate, convert, validate, build }
|
||||
|
||||
if (!command || !commands[command]) {
|
||||
console.log(`
|
||||
Theme Generator for markstream-vue
|
||||
|
||||
Commands:
|
||||
build Build all themes from registry.json
|
||||
generate Generate a single theme CSS from key colors
|
||||
convert Convert a CSS color to shadcn HSL / OKLCH
|
||||
validate Check WCAG contrast for a theme
|
||||
|
||||
Run with --help for per-command usage.
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
commands[command](args)
|
||||
|
||||
// ─── Commands ───────────────────────────────────────────────────────
|
||||
|
||||
function convert(args) {
|
||||
const color = args[0]
|
||||
if (!color) {
|
||||
console.error('Usage: convert <color>\n e.g. convert "#c96442"\n e.g. convert "oklch(0.6 0.15 40)"')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const hsl = toHsl(color)
|
||||
const shadcn = toShadcnHsl(color)
|
||||
const hex = hslObjToHex(hsl)
|
||||
const oklch = toOklch(color)
|
||||
const oklchCss = toOklchCss(color)
|
||||
|
||||
console.log(`Input: ${color}`)
|
||||
console.log(`hex: ${hex}`)
|
||||
console.log(`HSL: h=${hsl.h} s=${hsl.s}% l=${hsl.l}%${hsl.a < 1 ? ` a=${hsl.a}` : ''}`)
|
||||
console.log(`shadcn: ${shadcn}`)
|
||||
console.log(`OKLCH: L=${oklch.l} C=${oklch.c} H=${oklch.h}${oklch.a < 1 ? ` a=${oklch.a}` : ''}`)
|
||||
console.log(`oklch(): ${oklchCss}`)
|
||||
}
|
||||
|
||||
function generate(args) {
|
||||
const opts = parseArgs(args)
|
||||
|
||||
if (opts.help) {
|
||||
console.log(`
|
||||
Usage: generate --name <name> --bg <hex> --fg <hex> --brand <hex>
|
||||
[--surface <hex>] [--border <hex>] [--link <hex>]
|
||||
[--error <hex>] [--secondary-text <hex>]
|
||||
[--brand-fg <hex>] [--ring <hex>]
|
||||
[--font-sans <stack>] [--font-mono <stack>] [--font-serif <stack>]
|
||||
[--out <dir>] [--format css|json]
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const name = opts.name || 'custom'
|
||||
const format = opts.format || 'css'
|
||||
const outDir = opts.out || resolve(__dirname, '../../themes')
|
||||
|
||||
if (!opts.bg || !opts.fg || !opts.brand) {
|
||||
console.error('Error: --bg, --fg, and --brand are required.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Build font overrides if any --font-* flags provided
|
||||
const fonts = {}
|
||||
if (opts['font-sans'])
|
||||
fonts.sans = opts['font-sans']
|
||||
if (opts['font-mono'])
|
||||
fonts.mono = opts['font-mono']
|
||||
if (opts['font-serif'])
|
||||
fonts.serif = opts['font-serif']
|
||||
|
||||
const keyColors = {
|
||||
background: opts.bg,
|
||||
foreground: opts.fg,
|
||||
brand: opts.brand,
|
||||
...(opts.surface && { surface: opts.surface }),
|
||||
...(opts.border && { border: opts.border }),
|
||||
...(opts.link && { link: opts.link }),
|
||||
...(opts.error && { error: opts.error }),
|
||||
...(opts['secondary-text'] && { secondaryText: opts['secondary-text'] }),
|
||||
...(opts['brand-fg'] && { brandForeground: opts['brand-fg'] }),
|
||||
...(opts.ring && { ring: opts.ring }),
|
||||
...(Object.keys(fonts).length > 0 && { fonts }),
|
||||
}
|
||||
|
||||
const { light, dark } = generateBothSchemes(keyColors)
|
||||
|
||||
// Validate contrast
|
||||
console.log(`\n── ${name} (light) ──`)
|
||||
printContrast(validateContrast(light))
|
||||
console.log(`\n── ${name} (dark) ──`)
|
||||
printContrast(validateContrast(dark))
|
||||
|
||||
// Output
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
if (format === 'json') {
|
||||
const path = resolve(outDir, `${name}.json`)
|
||||
writeFileSync(path, JSON.stringify({ name, light, dark }, null, 2))
|
||||
console.log(`\nWritten: ${path}`)
|
||||
}
|
||||
else {
|
||||
const css = renderThemeCss(name, light, dark)
|
||||
const path = resolve(outDir, `${name}.css`)
|
||||
writeFileSync(path, css)
|
||||
console.log(`\nWritten: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validate(args) {
|
||||
const opts = parseArgs(args)
|
||||
const filePath = opts.file || args[0]
|
||||
|
||||
if (!filePath) {
|
||||
console.error('Usage: validate --file <path-to-theme.css>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const css = readFileSync(resolve(filePath), 'utf-8')
|
||||
const tokens = parseCssTokens(css)
|
||||
|
||||
if (tokens.light) {
|
||||
console.log('\n── Light scheme ──')
|
||||
printContrast(validateContrast(tokens.light))
|
||||
}
|
||||
if (tokens.dark) {
|
||||
console.log('\n── Dark scheme ──')
|
||||
printContrast(validateContrast(tokens.dark))
|
||||
}
|
||||
}
|
||||
|
||||
function build(args) {
|
||||
const opts = parseArgs(args)
|
||||
const registryPath = opts.registry || resolve(__dirname, 'registry.json')
|
||||
const outDir = opts.out || resolve(__dirname, '../../themes')
|
||||
const filter = opts.only ? opts.only.split(',') : null
|
||||
|
||||
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'))
|
||||
const entries = filter
|
||||
? registry.filter(e => filter.includes(e.id) || filter.includes(e.category))
|
||||
: registry
|
||||
|
||||
console.log(`Building ${entries.length} themes from registry...\n`)
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
let passCount = 0
|
||||
let warnCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const entry of entries) {
|
||||
const keyColors = {
|
||||
background: entry.colors.background,
|
||||
foreground: entry.colors.foreground,
|
||||
brand: entry.colors.brand,
|
||||
...(entry.colors.surface && { surface: entry.colors.surface }),
|
||||
...(entry.colors.secondaryText && { secondaryText: entry.colors.secondaryText }),
|
||||
...(entry.colors.border && { border: entry.colors.border }),
|
||||
...(entry.colors.error && { error: entry.colors.error }),
|
||||
...(entry.colors.link && { link: entry.colors.link }),
|
||||
...(entry.colors.brandForeground && { brandForeground: entry.colors.brandForeground }),
|
||||
...(entry.colors.ring && { ring: entry.colors.ring }),
|
||||
...(entry.colors.info && { info: entry.colors.info }),
|
||||
...(entry.colors.success && { success: entry.colors.success }),
|
||||
...(entry.colors.warning && { warning: entry.colors.warning }),
|
||||
...(entry.colors.highlight && { highlight: entry.colors.highlight }),
|
||||
...(entry.colors.diffAdded && { diffAdded: entry.colors.diffAdded }),
|
||||
...(entry.colors.diffRemoved && { diffRemoved: entry.colors.diffRemoved }),
|
||||
...(entry.fonts && { fonts: entry.fonts }),
|
||||
...(entry.radius && { radius: entry.radius }),
|
||||
...(entry.dark && { dark: entry.dark }),
|
||||
}
|
||||
|
||||
const { light, dark } = generateBothSchemes(keyColors)
|
||||
|
||||
const lightReport = validateContrast(light)
|
||||
const darkReport = validateContrast(dark)
|
||||
const errors = [...lightReport, ...darkReport].filter(r => !r.passAA && r.severity === 'error').length
|
||||
const warns = [...lightReport, ...darkReport].filter(r => !r.passAA && r.severity === 'warn').length
|
||||
|
||||
let status
|
||||
if (errors > 0) {
|
||||
status = `✗ ${errors}`
|
||||
failCount++
|
||||
}
|
||||
else if (warns > 0) {
|
||||
status = `~ ${warns}`
|
||||
warnCount++
|
||||
}
|
||||
else {
|
||||
status = '✓'
|
||||
passCount++
|
||||
}
|
||||
|
||||
const css = renderThemeCss(entry.id, light, dark)
|
||||
const path = resolve(outDir, `${entry.id}.css`)
|
||||
writeFileSync(path, css)
|
||||
|
||||
const cat = (entry.category || '').padEnd(10)
|
||||
const mode = entry.native === 'dark' ? '◐' : '◑'
|
||||
console.log(` ${status} ${mode} ${cat} ${entry.id}`)
|
||||
}
|
||||
|
||||
console.log(`\n${passCount} pass, ${warnCount} warn, ${failCount} fail — ${entries.length} themes → themes/`)
|
||||
|
||||
// Also generate an index CSS that imports all themes
|
||||
const indexLines = entries.map(e => `@import './${e.id}.css';`)
|
||||
writeFileSync(resolve(outDir, 'index.css'), `${indexLines.join('\n')}\n`)
|
||||
console.log(`Index written → themes/index.css`)
|
||||
}
|
||||
|
||||
// ─── CSS rendering ──────────────────────────────────────────────────
|
||||
|
||||
function splitTokens(tokens) {
|
||||
const colors = {}
|
||||
const shared = {}
|
||||
for (const [key, val] of Object.entries(tokens)) {
|
||||
if (NON_COLOR_TOKENS.has(key))
|
||||
shared[key] = val
|
||||
else
|
||||
colors[key] = val
|
||||
}
|
||||
return { colors, shared }
|
||||
}
|
||||
|
||||
function renderThemeCss(name, light, dark) {
|
||||
const { colors: lightColors, shared } = splitTokens(light)
|
||||
const { colors: darkColors } = splitTokens(dark)
|
||||
|
||||
const lines = []
|
||||
lines.push(`/**`)
|
||||
lines.push(` * markstream-vue theme: ${name}`)
|
||||
lines.push(` * Auto-generated by scripts/theme-gen/cli.mjs`)
|
||||
lines.push(` */`)
|
||||
lines.push('')
|
||||
|
||||
// Shared tokens (fonts, radius — mode-independent)
|
||||
if (Object.keys(shared).length > 0) {
|
||||
lines.push(`/* ── Shared ── */`)
|
||||
lines.push(`.markstream-vue[data-theme="${name}"] {`)
|
||||
for (const [key, val] of Object.entries(shared)) {
|
||||
lines.push(` --ms-${key}: ${val};`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push(`/* ── Light ── */`)
|
||||
lines.push(`.markstream-vue[data-theme="${name}"] {`)
|
||||
for (const [key, val] of Object.entries(lightColors)) {
|
||||
lines.push(` --ms-${key}: ${val};`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
lines.push(`/* ── Dark ── */`)
|
||||
lines.push(`.dark .markstream-vue[data-theme="${name}"],`)
|
||||
lines.push(`.markstream-vue[data-theme="${name}"].dark {`)
|
||||
for (const [key, val] of Object.entries(darkColors)) {
|
||||
lines.push(` --ms-${key}: ${val};`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs(args) {
|
||||
const result = {}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith('--')) {
|
||||
const key = args[i].slice(2)
|
||||
const next = args[i + 1]
|
||||
if (!next || next.startsWith('--')) {
|
||||
result[key] = true
|
||||
}
|
||||
else {
|
||||
result[key] = next
|
||||
i++
|
||||
}
|
||||
}
|
||||
else {
|
||||
result._positional = result._positional || []
|
||||
result._positional.push(args[i])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse --ms-* tokens from a CSS file (simple regex, not a full parser).
|
||||
*/
|
||||
function parseCssTokens(css) {
|
||||
const result = {}
|
||||
// Find light block (first rule block)
|
||||
const lightMatch = css.match(/\.markstream-vue\[data-theme[^\]]+\]\s*\{([^}]+)\}/)
|
||||
if (lightMatch) {
|
||||
result.light = extractTokens(lightMatch[1])
|
||||
}
|
||||
// Find dark block
|
||||
const darkMatch = css.match(/\.dark[^{]*\{([^}]+)\}\s*$/)
|
||||
if (darkMatch) {
|
||||
result.dark = extractTokens(darkMatch[1])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function extractTokens(block) {
|
||||
const tokens = {}
|
||||
for (const line of block.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('--ms-'))
|
||||
continue
|
||||
const colonIndex = trimmed.indexOf(':')
|
||||
const semicolonIndex = trimmed.lastIndexOf(';')
|
||||
if (colonIndex === -1 || semicolonIndex === -1 || semicolonIndex <= colonIndex)
|
||||
continue
|
||||
const key = trimmed.slice('--ms-'.length, colonIndex).trim()
|
||||
const value = trimmed.slice(colonIndex + 1, semicolonIndex).trim()
|
||||
if (!key || !value)
|
||||
continue
|
||||
tokens[key] = value
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
function printContrast(results) {
|
||||
for (const r of results) {
|
||||
const icon = r.passAA ? (r.passAAA ? '◉' : '○') : (r.severity === 'error' ? '✗' : '~')
|
||||
const ratio = r.ratio.toFixed(1).padStart(5)
|
||||
const sev = r.severity === 'warn' && !r.passAA ? ' [warn]' : ''
|
||||
console.log(` ${icon} ${ratio}:1 ${r.pair} (≥${r.threshold})${sev}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* Color conversion utilities for theme generation.
|
||||
* Zero dependencies — pure math.
|
||||
*
|
||||
* Supported color spaces: sRGB, HSL, OKLCH.
|
||||
* All public functions accept flexible input (hex, rgba, hsl, oklch string)
|
||||
* and output either structured objects or shadcn bare format "H S% L%".
|
||||
*/
|
||||
|
||||
// ─── Parsing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse any CSS color string into { r, g, b, a } (0-255 / 0-1).
|
||||
* Supports: #rgb, #rrggbb, #rrggbbaa, rgb(), rgba(), hsl(), hsla(), oklch().
|
||||
*/
|
||||
export function parseColor(str) {
|
||||
str = str.trim()
|
||||
|
||||
// hex
|
||||
if (str.startsWith('#'))
|
||||
return parseHex(str)
|
||||
|
||||
// rgb / rgba
|
||||
const rgbArgs = extractFunctionArgs(str, ['rgb', 'rgba'])
|
||||
if (rgbArgs)
|
||||
return parseRgbArgs(rgbArgs)
|
||||
|
||||
// hsl / hsla → convert to rgb internally
|
||||
const hslArgs = extractFunctionArgs(str, ['hsl', 'hsla'])
|
||||
if (hslArgs)
|
||||
return parseHslArgs(hslArgs)
|
||||
|
||||
// oklch → convert to rgb via OKLab → linear RGB → sRGB
|
||||
const oklchArgs = extractFunctionArgs(str, ['oklch'])
|
||||
if (oklchArgs)
|
||||
return parseOklchArgs(oklchArgs)
|
||||
|
||||
throw new Error(`Unsupported color format: "${str}"`)
|
||||
}
|
||||
|
||||
function extractFunctionArgs(str, names) {
|
||||
const lower = str.toLowerCase()
|
||||
for (const name of names) {
|
||||
const prefix = `${name}(`
|
||||
if (lower.startsWith(prefix) && str.endsWith(')'))
|
||||
return str.slice(prefix.length, -1).trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseHex(hex) {
|
||||
hex = hex.replace('#', '')
|
||||
if (hex.length === 3)
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
|
||||
if (hex.length === 4)
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
||||
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16)
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16)
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16)
|
||||
const a = hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1
|
||||
|
||||
return { r, g, b, a }
|
||||
}
|
||||
|
||||
function parseRgbArgs(args) {
|
||||
// Handles both comma-separated and space-separated (with optional / alpha)
|
||||
// Channels can be 0-255 or 0%-100% (100% = 255)
|
||||
const parts = args.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
|
||||
const r = parseRgbChannel(parts[0])
|
||||
const g = parseRgbChannel(parts[1])
|
||||
const b = parseRgbChannel(parts[2])
|
||||
const a = parts[3] !== undefined ? parseAlpha(parts[3]) : 1
|
||||
return { r, g, b, a }
|
||||
}
|
||||
|
||||
function parseRgbChannel(v) {
|
||||
if (v.endsWith('%'))
|
||||
return Math.round(Number.parseFloat(v) / 100 * 255)
|
||||
return Number.parseFloat(v)
|
||||
}
|
||||
|
||||
function parseHslArgs(args) {
|
||||
// H: angle — deg (default), rad, grad, turn
|
||||
// S, L: percentage (the % sign is required per spec but we tolerate bare numbers)
|
||||
const parts = args.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
|
||||
let h = Number.parseFloat(parts[0])
|
||||
h = parseAngle(parts[0], h)
|
||||
const s = Number.parseFloat(parts[1]) / 100
|
||||
const l = Number.parseFloat(parts[2]) / 100
|
||||
const a = parts[3] !== undefined ? parseAlpha(parts[3]) : 1
|
||||
const { r, g, b } = hslToRgbRaw(h, s, l)
|
||||
return { r, g, b, a }
|
||||
}
|
||||
|
||||
function parseOklchArgs(args) {
|
||||
// CSS oklch: oklch(L C H) or oklch(L C H / alpha)
|
||||
// L: 0-1 or 0%-100% (100% = 1)
|
||||
// C: 0-0.4 or 0%-100% (100% = 0.4, per CSS Color 4)
|
||||
// H: angle — deg (default), rad, grad, turn
|
||||
const parts = args.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
|
||||
|
||||
let l = Number.parseFloat(parts[0])
|
||||
if (parts[0].endsWith('%'))
|
||||
l /= 100
|
||||
l = Math.min(Math.max(l, 0), 1)
|
||||
|
||||
let c = Number.parseFloat(parts[1])
|
||||
if (parts[1]?.endsWith('%'))
|
||||
c = c / 100 * 0.4 // CSS Color 4: 100% chroma = 0.4
|
||||
|
||||
let h = Number.parseFloat(parts[2]) || 0
|
||||
h = parseAngle(parts[2], h)
|
||||
|
||||
const a = parts[3] !== undefined ? parseAlpha(parts[3]) : 1
|
||||
|
||||
const { r, g, b } = oklchToSrgb(l, c, h)
|
||||
return { r, g, b, a }
|
||||
}
|
||||
|
||||
function parseAlpha(v) {
|
||||
if (v.endsWith('%'))
|
||||
return Number.parseFloat(v) / 100
|
||||
return Number.parseFloat(v)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CSS angle units. Returns degrees.
|
||||
* Supports: deg (default), rad, grad, turn.
|
||||
* Note: 'grad' must be checked before 'rad' since 'grad'.endsWith('rad') === true.
|
||||
*/
|
||||
function parseAngle(raw, numericValue) {
|
||||
if (!raw)
|
||||
return numericValue
|
||||
if (raw.endsWith('grad'))
|
||||
return numericValue * 0.9
|
||||
if (raw.endsWith('rad'))
|
||||
return numericValue * 180 / Math.PI
|
||||
if (raw.endsWith('turn'))
|
||||
return numericValue * 360
|
||||
// 'deg' suffix or bare number — already in degrees
|
||||
return numericValue
|
||||
}
|
||||
|
||||
// ─── OKLCH ↔ sRGB conversion chain ─────────────────────────────────
|
||||
//
|
||||
// OKLCH ↔ OKLab ↔ LMS (cube) ↔ Linear RGB ↔ sRGB (gamma)
|
||||
//
|
||||
// Reference: Björn Ottosson — https://bottosson.github.io/posts/oklab/
|
||||
|
||||
/**
|
||||
* OKLCH → sRGB { r, g, b } (0-255).
|
||||
* Uses CSS Color 4 gamut mapping: binary-search chroma reduction in OKLCH
|
||||
* to preserve lightness and hue. Falls back to simple clamp only for
|
||||
* achromatic colors or near-boundary values.
|
||||
*
|
||||
* @param {number} L Lightness 0-1
|
||||
* @param {number} C Chroma ≥ 0
|
||||
* @param {number} H Hue 0-360
|
||||
*/
|
||||
function oklchToSrgb(L, C, H) {
|
||||
// Fast path: achromatic or no chroma
|
||||
if (C <= 0 || Number.isNaN(H))
|
||||
return oklabToLinearSrgb_gammaEncode(L, 0, 0)
|
||||
|
||||
// Try direct conversion first
|
||||
const direct = oklchToLinearSrgbChannels(L, C, H)
|
||||
if (isInGamut(direct))
|
||||
return gammaEncodeRgb(direct)
|
||||
|
||||
// CSS Color 4 gamut mapping: binary search on chroma, keep L and H fixed.
|
||||
// Tolerance: ΔE_ok < 0.002 (spec uses 0.02 in deltaEOK, ≈ 0.002 in raw OKLab)
|
||||
const EPSILON = 0.001
|
||||
let lo = 0
|
||||
let hi = C
|
||||
let mapped = direct
|
||||
|
||||
while (hi - lo > EPSILON) {
|
||||
const mid = (lo + hi) / 2
|
||||
mapped = oklchToLinearSrgbChannels(L, mid, H)
|
||||
if (isInGamut(mapped))
|
||||
lo = mid
|
||||
else
|
||||
hi = mid
|
||||
}
|
||||
|
||||
// Use the last in-gamut result (lo), clamp rounding errors
|
||||
mapped = oklchToLinearSrgbChannels(L, lo, H)
|
||||
return gammaEncodeRgb(mapped)
|
||||
}
|
||||
|
||||
/** Check if linear RGB channels are within [0, 1] gamut (with tiny tolerance). */
|
||||
function isInGamut({ lr, lg, lb }) {
|
||||
const E = 0.000075 // tolerance for floating point
|
||||
return lr >= -E && lr <= 1 + E
|
||||
&& lg >= -E && lg <= 1 + E
|
||||
&& lb >= -E && lb <= 1 + E
|
||||
}
|
||||
|
||||
/** OKLCH → raw linear sRGB channels (may be out of gamut). */
|
||||
function oklchToLinearSrgbChannels(L, C, H) {
|
||||
const hRad = H * Math.PI / 180
|
||||
const a = C * Math.cos(hRad)
|
||||
const b = C * Math.sin(hRad)
|
||||
|
||||
// OKLab → LMS (cube root form)
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b
|
||||
const s_ = L - 0.0894841775 * a - 1.2914855480 * b
|
||||
|
||||
const l = l_ * l_ * l_
|
||||
const m = m_ * m_ * m_
|
||||
const s = s_ * s_ * s_
|
||||
|
||||
return {
|
||||
lr: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
lg: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
lb: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||
}
|
||||
}
|
||||
|
||||
/** Gamma-encode and clamp linear RGB channels → sRGB { r, g, b } 0-255. */
|
||||
function gammaEncodeRgb({ lr, lg, lb }) {
|
||||
return {
|
||||
r: clamp(Math.round(linearToGamma(clamp(lr, 0, 1)) * 255), 0, 255),
|
||||
g: clamp(Math.round(linearToGamma(clamp(lg, 0, 1)) * 255), 0, 255),
|
||||
b: clamp(Math.round(linearToGamma(clamp(lb, 0, 1)) * 255), 0, 255),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OKLab → sRGB { r, g, b } (0-255). Direct conversion with gamma encode + clamp.
|
||||
* Used internally for achromatic fast path.
|
||||
*/
|
||||
function oklabToLinearSrgb_gammaEncode(L) {
|
||||
// For achromatic, a=0 b=0 so we just need L → LMS → linear RGB
|
||||
const l_ = L
|
||||
const m_ = L
|
||||
const s_ = L
|
||||
const l = l_ * l_ * l_
|
||||
const m = m_ * m_ * m_
|
||||
const s = s_ * s_ * s_
|
||||
|
||||
return gammaEncodeRgb({
|
||||
lr: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
lg: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
lb: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* sRGB { r, g, b } (0-255) → OKLab { L, a, b }.
|
||||
*/
|
||||
function srgbToOklab(r, g, b) {
|
||||
// sRGB → Linear sRGB
|
||||
const lr = gammaToLinear(r / 255)
|
||||
const lg = gammaToLinear(g / 255)
|
||||
const lb = gammaToLinear(b / 255)
|
||||
|
||||
// Linear sRGB → LMS
|
||||
const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb
|
||||
const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb
|
||||
const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb
|
||||
|
||||
// Cube root
|
||||
const l_ = Math.cbrt(l)
|
||||
const m_ = Math.cbrt(m)
|
||||
const s_ = Math.cbrt(s)
|
||||
|
||||
// LMS → OKLab
|
||||
return {
|
||||
L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
||||
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
||||
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sRGB { r, g, b } (0-255) → OKLCH { l: 0-1, c: ≥0, h: 0-360 }.
|
||||
*/
|
||||
export function rgbToOklch(r, g, b) {
|
||||
const { L, a, b: b_ } = srgbToOklab(r, g, b)
|
||||
const c = Math.sqrt(a * a + b_ * b_)
|
||||
let h = Math.atan2(b_, a) * 180 / Math.PI
|
||||
if (h < 0)
|
||||
h += 360
|
||||
return {
|
||||
l: round(L, 4),
|
||||
c: round(c, 4),
|
||||
h: round(h, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OKLCH { l, c, h } → sRGB { r, g, b } (0-255).
|
||||
*/
|
||||
export function oklchToRgb(l, c, h) {
|
||||
return oklchToSrgb(l, c, h)
|
||||
}
|
||||
|
||||
/** sRGB gamma encode (linear → gamma). */
|
||||
function linearToGamma(x) {
|
||||
if (x <= 0)
|
||||
return 0
|
||||
if (x >= 1)
|
||||
return 1
|
||||
return x <= 0.0031308
|
||||
? 12.92 * x
|
||||
: 1.055 * (x ** (1 / 2.4)) - 0.055
|
||||
}
|
||||
|
||||
/** sRGB gamma decode (gamma → linear). */
|
||||
function gammaToLinear(x) {
|
||||
if (x <= 0)
|
||||
return 0
|
||||
if (x >= 1)
|
||||
return 1
|
||||
return x <= 0.04045
|
||||
? x / 12.92
|
||||
: ((x + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
// ─── Conversions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* RGB (0-255) → HSL { h: 0-360, s: 0-100, l: 0-100 }
|
||||
*/
|
||||
export function rgbToHsl(r, g, b) {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const d = max - min
|
||||
const l = (max + min) / 2
|
||||
|
||||
let h = 0
|
||||
let s = 0
|
||||
|
||||
if (d !== 0) {
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6
|
||||
break
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6
|
||||
break
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
h: round(h * 360, 1),
|
||||
s: round(s * 100, 1),
|
||||
l: round(l * 100, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL (h: 0-360, s: 0-1, l: 0-1) → RGB { r, g, b } (0-255)
|
||||
*/
|
||||
function hslToRgbRaw(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360
|
||||
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s
|
||||
const x = c * (1 - Math.abs((h / 60) % 2 - 1))
|
||||
const m = l - c / 2
|
||||
|
||||
let r, g, b
|
||||
if (h < 60)
|
||||
[r, g, b] = [c, x, 0]
|
||||
else if (h < 120)
|
||||
[r, g, b] = [x, c, 0]
|
||||
else if (h < 180)
|
||||
[r, g, b] = [0, c, x]
|
||||
else if (h < 240)
|
||||
[r, g, b] = [0, x, c]
|
||||
else if (h < 300)
|
||||
[r, g, b] = [x, 0, c]
|
||||
else [r, g, b] = [c, 0, x]
|
||||
|
||||
return {
|
||||
r: Math.round((r + m) * 255),
|
||||
g: Math.round((g + m) * 255),
|
||||
b: Math.round((b + m) * 255),
|
||||
}
|
||||
}
|
||||
|
||||
export function hslToRgb(h, s, l) {
|
||||
return hslToRgbRaw(h, s / 100, l / 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL → hex string "#rrggbb"
|
||||
*/
|
||||
export function hslToHex(h, s, l) {
|
||||
const { r, g, b } = hslToRgb(h, s, l)
|
||||
return `#${[r, g, b].map(c => c.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
// ─── Format helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Any CSS color string → shadcn bare HSL format "H S% L%"
|
||||
* e.g. "222.2 84% 4.9%"
|
||||
*/
|
||||
export function toShadcnHsl(color) {
|
||||
const { r, g, b } = parseColor(color)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
return `${h} ${s}% ${l}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* Any CSS color string → { h, s, l, a } (h: 0-360, s: 0-100, l: 0-100)
|
||||
*/
|
||||
export function toHsl(color) {
|
||||
const { r, g, b, a } = parseColor(color)
|
||||
const { h, s, l } = rgbToHsl(r, g, b)
|
||||
return { h, s, l, a }
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL object → shadcn bare format "H S% L%"
|
||||
*/
|
||||
export function hslToShadcn({ h, s, l }) {
|
||||
return `${round(h, 1)} ${round(s, 1)}% ${round(l, 1)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL object → hex "#rrggbb"
|
||||
*/
|
||||
export function hslObjToHex({ h, s, l }) {
|
||||
return hslToHex(h, s, l)
|
||||
}
|
||||
|
||||
// ─── OKLCH format helpers ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Any CSS color string → OKLCH { l: 0-1, c: ≥0, h: 0-360, a: 0-1 }
|
||||
*/
|
||||
export function toOklch(color) {
|
||||
const { r, g, b, a } = parseColor(color)
|
||||
const oklch = rgbToOklch(r, g, b)
|
||||
return { ...oklch, a }
|
||||
}
|
||||
|
||||
/**
|
||||
* Any CSS color string → CSS oklch() string "oklch(L C H)"
|
||||
*/
|
||||
export function toOklchCss(color) {
|
||||
const { l, c, h, a } = toOklch(color)
|
||||
const base = `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)})`
|
||||
if (a < 1)
|
||||
return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)} / ${round(a, 3)})`
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* OKLCH object → CSS oklch() string
|
||||
*/
|
||||
export function oklchToCss({ l, c, h, a }) {
|
||||
if (a !== undefined && a < 1)
|
||||
return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)} / ${round(a, 3)})`
|
||||
return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)})`
|
||||
}
|
||||
|
||||
/**
|
||||
* OKLCH object → hex "#rrggbb"
|
||||
*/
|
||||
export function oklchToHex({ l, c, h }) {
|
||||
const { r, g, b } = oklchToRgb(l, c, h)
|
||||
return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL object { h, s, l } → OKLCH { l, c, h }
|
||||
*/
|
||||
export function hslToOklch({ h, s, l }) {
|
||||
const { r, g, b } = hslToRgb(h, s, l)
|
||||
return rgbToOklch(r, g, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* OKLCH { l, c, h } → HSL { h, s, l } (all 0-360/0-100 range)
|
||||
*/
|
||||
export function oklchToHsl({ l, c, h }) {
|
||||
const { r, g, b } = oklchToRgb(l, c, h)
|
||||
return rgbToHsl(r, g, b)
|
||||
}
|
||||
|
||||
// ─── Color manipulation ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adjust lightness: positive = lighter, negative = darker.
|
||||
* Clamps 0-100.
|
||||
*/
|
||||
export function adjustLightness(hsl, amount) {
|
||||
return { ...hsl, l: clamp(hsl.l + amount, 0, 100) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust saturation: positive = more saturated, negative = less.
|
||||
* Clamps 0-100.
|
||||
*/
|
||||
export function adjustSaturation(hsl, amount) {
|
||||
return { ...hsl, s: clamp(hsl.s + amount, 0, 100) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mix two HSL colors by ratio (0 = all colorA, 1 = all colorB).
|
||||
*/
|
||||
export function mixHsl(a, b, ratio = 0.5) {
|
||||
return {
|
||||
h: lerpHue(a.h, b.h, ratio),
|
||||
s: lerp(a.s, b.s, ratio),
|
||||
l: lerp(a.l, b.l, ratio),
|
||||
a: lerp(a.a ?? 1, b.a ?? 1, ratio),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mix two colors in OKLCH space (perceptually uniform).
|
||||
* Inputs/outputs are OKLCH { l, c, h }.
|
||||
*/
|
||||
export function mixOklch(a, b, ratio = 0.5) {
|
||||
return {
|
||||
l: lerp(a.l, b.l, ratio),
|
||||
c: lerp(a.c, b.c, ratio),
|
||||
h: lerpHue(a.h, b.h, ratio),
|
||||
a: lerp(a.a ?? 1, b.a ?? 1, ratio),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust OKLCH lightness: positive = lighter, negative = darker.
|
||||
* Clamps 0-1.
|
||||
*/
|
||||
export function adjustOklchLightness(oklch, amount) {
|
||||
return { ...oklch, l: clamp(oklch.l + amount, 0, 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust OKLCH chroma: positive = more vivid, negative = more muted.
|
||||
* Clamps ≥ 0.
|
||||
*/
|
||||
export function adjustOklchChroma(oklch, amount) {
|
||||
return { ...oklch, c: Math.max(0, oklch.c + amount) }
|
||||
}
|
||||
|
||||
// ─── WCAG Contrast ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Relative luminance from HSL (WCAG 2.1 definition).
|
||||
*/
|
||||
export function luminance(hsl) {
|
||||
const { r, g, b } = hslToRgb(hsl.h, hsl.s, hsl.l)
|
||||
const [rs, gs, bs] = [r, g, b].map((c) => {
|
||||
c /= 255
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
|
||||
}
|
||||
|
||||
/**
|
||||
* WCAG contrast ratio between two HSL colors. Returns 1–21.
|
||||
*/
|
||||
export function contrastRatio(a, b) {
|
||||
const la = luminance(a)
|
||||
const lb = luminance(b)
|
||||
const lighter = Math.max(la, lb)
|
||||
const darker = Math.min(la, lb)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if contrast meets WCAG AA (4.5:1 for normal text, 3:1 for large text).
|
||||
*/
|
||||
export function meetsAA(fg, bg, largeText = false) {
|
||||
return contrastRatio(fg, bg) >= (largeText ? 3 : 4.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if contrast meets WCAG AAA (7:1 for normal text, 4.5:1 for large text).
|
||||
*/
|
||||
export function meetsAAA(fg, bg, largeText = false) {
|
||||
return contrastRatio(fg, bg) >= (largeText ? 4.5 : 7)
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────
|
||||
|
||||
function round(n, decimals = 1) {
|
||||
const f = 10 ** decimals
|
||||
return Math.round(n * f) / f
|
||||
}
|
||||
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(max, Math.max(min, v))
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate hue on the shortest arc.
|
||||
*/
|
||||
function lerpHue(a, b, t) {
|
||||
let diff = b - a
|
||||
if (diff > 180)
|
||||
diff -= 360
|
||||
if (diff < -180)
|
||||
diff += 360
|
||||
return ((a + diff * t) % 360 + 360) % 360
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Dark / light scheme generator.
|
||||
*
|
||||
* Takes a palette token map (from palette.mjs) and produces the opposite
|
||||
* scheme. Preserves hue identity, adjusts lightness/saturation, and
|
||||
* validates WCAG contrast.
|
||||
*
|
||||
* Strategy:
|
||||
* Light → Dark: backgrounds go very dark, text goes light, brand adjusts for contrast
|
||||
* Dark → Light: backgrounds go very light, text goes dark, brand adjusts for contrast
|
||||
*/
|
||||
|
||||
import {
|
||||
adjustLightness,
|
||||
contrastRatio,
|
||||
hslToRgb,
|
||||
meetsAA,
|
||||
rgbToHsl,
|
||||
toHsl,
|
||||
} from './color.mjs'
|
||||
import { generatePalette } from './palette.mjs'
|
||||
|
||||
// ─── Main entry ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given a token map (Record<string, "H S% L%">), generate the opposite scheme.
|
||||
*
|
||||
* @param {Record<string, string>} tokens - Token map from generatePalette()
|
||||
* @param {'light'|'dark'} sourceMode - What mode the input tokens represent
|
||||
* @returns {Record<string, string>} Token map for the opposite mode
|
||||
*/
|
||||
export function generateOppositeScheme(tokens, sourceMode) {
|
||||
const bg = parseShadcnHsl(tokens.background)
|
||||
const fg = parseShadcnHsl(tokens.foreground)
|
||||
const brand = parseShadcnHsl(tokens.primary)
|
||||
|
||||
const targetIsLight = sourceMode === 'dark'
|
||||
|
||||
// Core strategy: flip lightness around the midpoint, keep hue + saturation close
|
||||
const newBg = flipForBackground(bg, targetIsLight)
|
||||
const newFg = flipForForeground(fg, targetIsLight, newBg)
|
||||
const newBrand = adjustBrandForScheme(brand, targetIsLight, newBg)
|
||||
|
||||
// Generate full palette from derived key colors.
|
||||
// Signal & content tokens are not passed — the opposite scheme
|
||||
// only emits them if the source registry provided them.
|
||||
return generatePalette({
|
||||
background: hslToHex(newBg),
|
||||
foreground: hslToHex(newFg),
|
||||
brand: hslToHex(newBrand),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate both light and dark from any single scheme.
|
||||
*
|
||||
* @param {object} keyColors - { background, foreground, brand, ... }
|
||||
* @returns {{ light: Record<string, string>, dark: Record<string, string> }}
|
||||
*/
|
||||
export function generateBothSchemes(keyColors) {
|
||||
const bg = toHsl(keyColors.background)
|
||||
const sourceIsLight = bg.l > 50
|
||||
|
||||
const primary = generatePalette(keyColors)
|
||||
|
||||
let opposite
|
||||
if (keyColors.dark && keyColors.dark.background && sourceIsLight) {
|
||||
// Explicit dark palette provided — use directly instead of algorithmic flip.
|
||||
// Missing fields fall back to light-side values.
|
||||
opposite = generatePalette({
|
||||
background: keyColors.dark.background,
|
||||
foreground: keyColors.dark.foreground || keyColors.foreground,
|
||||
brand: keyColors.dark.brand || keyColors.brand,
|
||||
...(keyColors.dark.surface && { surface: keyColors.dark.surface }),
|
||||
...((keyColors.dark.secondaryText || keyColors.secondaryText) && {
|
||||
secondaryText: keyColors.dark.secondaryText || keyColors.secondaryText,
|
||||
}),
|
||||
...((keyColors.dark.border) && { border: keyColors.dark.border }),
|
||||
...((keyColors.dark.error || keyColors.error) && {
|
||||
error: keyColors.dark.error || keyColors.error,
|
||||
}),
|
||||
...((keyColors.dark.link || keyColors.link) && {
|
||||
link: keyColors.dark.link || keyColors.link,
|
||||
}),
|
||||
...((keyColors.dark.brandForeground || keyColors.brandForeground) && {
|
||||
brandForeground: keyColors.dark.brandForeground || keyColors.brandForeground,
|
||||
}),
|
||||
...((keyColors.dark.ring) && { ring: keyColors.dark.ring }),
|
||||
...((keyColors.dark.info || keyColors.info) && {
|
||||
info: keyColors.dark.info || keyColors.info,
|
||||
}),
|
||||
...((keyColors.dark.success || keyColors.success) && {
|
||||
success: keyColors.dark.success || keyColors.success,
|
||||
}),
|
||||
...((keyColors.dark.warning || keyColors.warning) && {
|
||||
warning: keyColors.dark.warning || keyColors.warning,
|
||||
}),
|
||||
...((keyColors.dark.highlight || keyColors.highlight) && {
|
||||
highlight: keyColors.dark.highlight || keyColors.highlight,
|
||||
}),
|
||||
...((keyColors.dark.diffAdded || keyColors.diffAdded) && {
|
||||
diffAdded: keyColors.dark.diffAdded || keyColors.diffAdded,
|
||||
}),
|
||||
...((keyColors.dark.diffRemoved || keyColors.diffRemoved) && {
|
||||
diffRemoved: keyColors.dark.diffRemoved || keyColors.diffRemoved,
|
||||
}),
|
||||
...(keyColors.fonts && { fonts: keyColors.fonts }),
|
||||
})
|
||||
}
|
||||
else {
|
||||
opposite = generateOppositeScheme(primary, sourceIsLight ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
// Shared tokens are mode-independent — copy from primary to opposite
|
||||
for (const key of ['font-sans', 'font-mono', 'font-serif', 'radius']) {
|
||||
if (primary[key] && !opposite[key])
|
||||
opposite[key] = primary[key]
|
||||
}
|
||||
|
||||
return sourceIsLight
|
||||
? { light: primary, dark: opposite }
|
||||
: { light: opposite, dark: primary }
|
||||
}
|
||||
|
||||
// ─── Lightness flipping ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flip background lightness for the target scheme.
|
||||
*/
|
||||
function flipForBackground(bg, targetIsLight) {
|
||||
if (targetIsLight) {
|
||||
// Dark → Light: target L around 97-100, keep hue, reduce saturation slightly
|
||||
return {
|
||||
h: bg.h,
|
||||
s: Math.max(bg.s * 0.6, 0),
|
||||
l: mapRange(bg.l, 0, 20, 96, 100),
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Light → Dark: target L around 4-10, keep hue, reduce saturation
|
||||
return {
|
||||
h: bg.h,
|
||||
s: Math.max(bg.s * 0.8, 0),
|
||||
l: mapRange(bg.l, 90, 100, 4, 10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip foreground lightness for the target scheme.
|
||||
*/
|
||||
function flipForForeground(fg, targetIsLight, newBg) {
|
||||
let newFg
|
||||
if (targetIsLight) {
|
||||
// Dark → Light: target L around 5-15
|
||||
newFg = {
|
||||
h: fg.h,
|
||||
s: Math.min(fg.s * 1.3, 100),
|
||||
l: mapRange(fg.l, 85, 100, 4, 12),
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Light → Dark: target L around 90-98
|
||||
newFg = {
|
||||
h: fg.h,
|
||||
s: Math.max(fg.s * 0.7, 0),
|
||||
l: mapRange(fg.l, 0, 15, 92, 98),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure sufficient contrast
|
||||
if (!meetsAA(newFg, newBg)) {
|
||||
newFg = targetIsLight
|
||||
? adjustLightness(newFg, -10)
|
||||
: adjustLightness(newFg, 10)
|
||||
}
|
||||
|
||||
return newFg
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust brand color for the target scheme — preserve identity, ensure contrast.
|
||||
*/
|
||||
function adjustBrandForScheme(brand, targetIsLight, newBg) {
|
||||
let adjusted = { ...brand }
|
||||
|
||||
if (targetIsLight) {
|
||||
// For light scheme, brand may need to be darker for contrast
|
||||
if (adjusted.l > 60)
|
||||
adjusted.l = 45
|
||||
}
|
||||
else {
|
||||
// For dark scheme, brand may need to be lighter for visibility
|
||||
if (adjusted.l < 40)
|
||||
adjusted.l = 55
|
||||
}
|
||||
|
||||
// Fine-tune for contrast against background
|
||||
const cr = contrastRatio(adjusted, newBg)
|
||||
if (cr < 3) {
|
||||
adjusted = targetIsLight
|
||||
? adjustLightness(adjusted, -15)
|
||||
: adjustLightness(adjusted, 15)
|
||||
}
|
||||
|
||||
return adjusted
|
||||
}
|
||||
|
||||
// ─── Contrast validation report ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a token map for WCAG contrast on all critical pairs.
|
||||
*
|
||||
* Each check has a severity:
|
||||
* 'error' — text readability, will cause real accessibility issues
|
||||
* 'warn' — best-practice, may not cause issues if supplemented (hover, underline)
|
||||
*
|
||||
* Returns an array of { pair, ratio, passAA, threshold, severity } objects.
|
||||
*/
|
||||
export function validateContrast(tokens) {
|
||||
// [fgKey, bgKey, label, threshold, severity]
|
||||
const pairs = [
|
||||
// Hard: text readability
|
||||
['foreground', 'background', 'Primary text', 4.5, 'error'],
|
||||
['muted-foreground', 'background', 'Secondary text', 4.5, 'error'],
|
||||
['primary-foreground', 'primary', 'Button text', 4.5, 'error'],
|
||||
['accent-foreground', 'accent', 'Accent text', 4.5, 'error'],
|
||||
['destructive-foreground', 'destructive', 'Error text', 4.5, 'error'],
|
||||
['popover-foreground', 'popover', 'Popover text', 4.5, 'error'],
|
||||
['link', 'background', 'Link on background', 4.5, 'error'],
|
||||
// Soft: supplementary / non-text
|
||||
['link', 'foreground', 'Link vs body text', 3, 'warn'],
|
||||
['border', 'background', 'Border visibility', 3, 'warn'],
|
||||
]
|
||||
|
||||
const bgL = parseShadcnHsl(tokens.background).l
|
||||
const isLight = bgL > 50
|
||||
|
||||
// Semantic colors as text on REAL admonition header background:
|
||||
// alphaBlend(semanticColor, muted, alpha) where alpha = 0.06 (light) / 0.12 (dark)
|
||||
if (tokens.muted) {
|
||||
const muted = parseShadcnHsl(tokens.muted)
|
||||
const alpha = isLight ? 0.06 : 0.12
|
||||
for (const key of ['info', 'success', 'warning']) {
|
||||
if (!tokens[key])
|
||||
continue
|
||||
const semColor = parseShadcnHsl(tokens[key])
|
||||
const headerBg = alphaBlendHsl(semColor, muted, alpha)
|
||||
const ratio = contrastRatio(semColor, headerBg)
|
||||
pairs.push([key, null, `${key} on header bg`, 4.5, 'error', ratio, headerBg])
|
||||
}
|
||||
}
|
||||
|
||||
return pairs.map(([fgKey, bgKey, label, threshold, severity, preRatio]) => {
|
||||
let ratio
|
||||
if (preRatio !== undefined) {
|
||||
ratio = preRatio
|
||||
}
|
||||
else {
|
||||
const fg = parseShadcnHsl(tokens[fgKey])
|
||||
const bg = parseShadcnHsl(tokens[bgKey])
|
||||
ratio = contrastRatio(fg, bg)
|
||||
}
|
||||
return {
|
||||
pair: label,
|
||||
fg: fgKey,
|
||||
bg: bgKey,
|
||||
ratio: Math.round(ratio * 100) / 100,
|
||||
passAA: ratio >= threshold,
|
||||
passAAA: ratio >= (threshold === 3 ? 4.5 : 7),
|
||||
threshold,
|
||||
severity,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Alpha-blend fg over bg in RGB space (same as CSS alpha compositing). */
|
||||
function alphaBlendHsl(fg, bg, alpha) {
|
||||
const f = hslToRgb(fg.h, fg.s, fg.l)
|
||||
const b = hslToRgb(bg.h, bg.s, bg.l)
|
||||
return rgbToHsl(
|
||||
Math.round(f.r * alpha + b.r * (1 - alpha)),
|
||||
Math.round(f.g * alpha + b.g * (1 - alpha)),
|
||||
Math.round(f.b * alpha + b.b * (1 - alpha)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse shadcn bare HSL "H S% L%" back to { h, s, l }.
|
||||
*/
|
||||
function parseShadcnHsl(str) {
|
||||
const parts = str.match(/[\d.]+/g)
|
||||
if (!parts || parts.length < 3)
|
||||
throw new Error(`Invalid shadcn HSL: "${str}"`)
|
||||
return {
|
||||
h: Number.parseFloat(parts[0]),
|
||||
s: Number.parseFloat(parts[1]),
|
||||
l: Number.parseFloat(parts[2]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HSL object → hex (for feeding back to generatePalette).
|
||||
*/
|
||||
function hslToHex({ h, s, l }) {
|
||||
// Import would create circular dep, inline the conversion
|
||||
s /= 100
|
||||
l /= 100
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s
|
||||
const x = c * (1 - Math.abs((h / 60) % 2 - 1))
|
||||
const m = l - c / 2
|
||||
let r, g, b
|
||||
if (h < 60)
|
||||
[r, g, b] = [c, x, 0]
|
||||
else if (h < 120)
|
||||
[r, g, b] = [x, c, 0]
|
||||
else if (h < 180)
|
||||
[r, g, b] = [0, c, x]
|
||||
else if (h < 240)
|
||||
[r, g, b] = [0, x, c]
|
||||
else if (h < 300)
|
||||
[r, g, b] = [x, 0, c]
|
||||
else [r, g, b] = [c, 0, x]
|
||||
|
||||
const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, '0')
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a value from one range to another, clamped.
|
||||
*/
|
||||
function mapRange(value, inMin, inMax, outMin, outMax) {
|
||||
const clamped = Math.min(Math.max(value, inMin), inMax)
|
||||
const ratio = (clamped - inMin) / (inMax - inMin)
|
||||
return outMin + ratio * (outMax - outMin)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Extract expanded registry fields from awesome-design-md preview HTML files.
|
||||
*
|
||||
* Reads preview.html and preview-dark.html for each theme to extract:
|
||||
* - dark section (background, foreground, surface, secondaryText, border)
|
||||
* - error color
|
||||
* - brandForeground (CTA button text)
|
||||
* - ring color
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/theme-gen/extract-registry.mjs --source ../awesome-design-md/design-md
|
||||
* node scripts/theme-gen/extract-registry.mjs --source ../awesome-design-md/design-md --only claude
|
||||
* node scripts/theme-gen/extract-registry.mjs --source ../awesome-design-md/design-md --dry-run
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { contrastRatio, toHsl } from './color.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// ─── CLI args ───────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const opts = {}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith('--')) {
|
||||
const key = args[i].slice(2)
|
||||
const next = args[i + 1]
|
||||
if (!next || next.startsWith('--')) {
|
||||
opts[key] = true
|
||||
}
|
||||
else {
|
||||
opts[key] = next
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceDir = opts.source || resolve(__dirname, '../../../awesome-design-md/design-md')
|
||||
const registryPath = resolve(__dirname, 'registry.json')
|
||||
const dryRun = opts['dry-run'] || false
|
||||
const onlyId = opts.only || null
|
||||
|
||||
// ─── Parse CSS variables from HTML ──────────────────────────────────
|
||||
|
||||
function extractCssVars(html) {
|
||||
const vars = {}
|
||||
for (const declaration of html.split(';')) {
|
||||
const tokenStart = declaration.lastIndexOf('--')
|
||||
if (tokenStart === -1)
|
||||
continue
|
||||
const chunk = declaration.slice(tokenStart).trim()
|
||||
const colonIndex = chunk.indexOf(':')
|
||||
if (colonIndex === -1)
|
||||
continue
|
||||
const key = chunk.slice(2, colonIndex).trim()
|
||||
const val = chunk.slice(colonIndex + 1).trim()
|
||||
if (!key || !val)
|
||||
continue
|
||||
vars[key] = val
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a CSS color value is opaque (no alpha channel < 1).
|
||||
*/
|
||||
function isOpaque(val) {
|
||||
if (val.startsWith('#')) {
|
||||
// #rrggbbaa — check if alpha < ff
|
||||
if (val.length === 9)
|
||||
return val.slice(7).toLowerCase() === 'ff'
|
||||
if (val.length === 5)
|
||||
return val[4].toLowerCase() === 'f'
|
||||
return true
|
||||
}
|
||||
const alphaValue = extractFunctionalAlpha(val)
|
||||
if (alphaValue != null) {
|
||||
const a = Number.parseFloat(alphaValue)
|
||||
return a >= 0.9
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function extractFunctionalAlpha(val) {
|
||||
const openIndex = val.indexOf('(')
|
||||
const closeIndex = val.lastIndexOf(')')
|
||||
if (openIndex === -1 || closeIndex === -1 || closeIndex <= openIndex)
|
||||
return null
|
||||
const inner = val.slice(openIndex + 1, closeIndex).trim()
|
||||
if (!inner)
|
||||
return null
|
||||
if (inner.includes('/'))
|
||||
return inner.split('/').pop()?.trim() ?? null
|
||||
const parts = inner.split(',').map(part => part.trim()).filter(Boolean)
|
||||
return parts.length === 4 ? parts[3] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a color value by trying multiple candidate variable names.
|
||||
* Only returns opaque solid colors (skips rgba with alpha < 0.9).
|
||||
*/
|
||||
function findColor(vars, ...candidates) {
|
||||
for (const name of candidates) {
|
||||
const val = vars[name]
|
||||
if (!val)
|
||||
continue
|
||||
if ((val.startsWith('#') || val.startsWith('rgb') || val.startsWith('hsl')) && isOpaque(val))
|
||||
return val
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CTA button text color from inline styles in HTML.
|
||||
* Looks for buttons with the brand background color and extracts their text color.
|
||||
*/
|
||||
function findBrandForeground(html, brandColor) {
|
||||
if (!brandColor)
|
||||
return null
|
||||
const brandHex = brandColor.toLowerCase()
|
||||
|
||||
// Look for button/CTA styles with brand background
|
||||
// Pattern: background: <brand>; color: <fg>;
|
||||
const brandBgRe = new RegExp(
|
||||
`style="[^"]*background:\\s*(?:var\\(--[^)]*\\)|${brandHex.replace('#', '#?')})[^"]*color:\\s*(#[\\da-f]{3,8})`,
|
||||
'gi',
|
||||
)
|
||||
|
||||
// Try matching brand background specifically
|
||||
const brandBgMatch = brandBgRe.exec(html)
|
||||
if (brandBgMatch)
|
||||
return brandBgMatch[1]
|
||||
|
||||
// Fallback: look for nav-cta or btn-brand class button colors
|
||||
const ctaRe = /\.(?:nav-cta|btn-brand|btn-primary)\s*\{[^}]*color:\s*(?:var\(--([\w-]+)\)|(#[\da-f]{3,8}))/gi
|
||||
let m
|
||||
while ((m = ctaRe.exec(html)) !== null) {
|
||||
if (m[2])
|
||||
return m[2]
|
||||
if (m[1]) {
|
||||
const vars = extractCssVars(html)
|
||||
const resolved = vars[m[1]]
|
||||
if (resolved && resolved.startsWith('#'))
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ring/focus color from CSS variables.
|
||||
*/
|
||||
function findRing(vars) {
|
||||
// Try specific ring variables
|
||||
const ring = findColor(vars, 'color-ring-warm', 'color-ring', 'ring-warm', 'ring', 'shadow-ring-color', 'focus-ring')
|
||||
if (ring)
|
||||
return ring
|
||||
|
||||
// Try parsing ring from shadow-ring value: "rgba(...) 0px 0px 0px 1px" or "color 0px 0px 0px 1px"
|
||||
for (const key of ['shadow-ring', 'shadow-ring-light']) {
|
||||
const val = vars[key]
|
||||
if (!val)
|
||||
continue
|
||||
const hexMatch = val.match(/(#[\da-f]{3,8})/i)
|
||||
if (hexMatch && isOpaque(hexMatch[1]))
|
||||
return hexMatch[1]
|
||||
const rgbMatch = val.match(/(rgb\([^)]+\))/)
|
||||
if (rgbMatch && isOpaque(rgbMatch[1]))
|
||||
return rgbMatch[1]
|
||||
// Skip rgba with alpha — those are overlays, not solid ring colors
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────
|
||||
|
||||
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'))
|
||||
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const entry of registry) {
|
||||
if (onlyId && entry.id !== onlyId)
|
||||
continue
|
||||
|
||||
// Skip Claude — already manually updated
|
||||
if (entry.id === 'claude') {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const themeDir = resolve(sourceDir, entry.source)
|
||||
let lightHtml, darkHtml
|
||||
try {
|
||||
lightHtml = readFileSync(resolve(themeDir, 'preview.html'), 'utf-8')
|
||||
darkHtml = readFileSync(resolve(themeDir, 'preview-dark.html'), 'utf-8')
|
||||
}
|
||||
catch {
|
||||
console.log(` skip ${entry.id} (no preview files at ${entry.source})`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const lightVars = extractCssVars(lightHtml)
|
||||
const darkVars = extractCssVars(darkHtml)
|
||||
|
||||
const changes = []
|
||||
|
||||
// ── radius ──
|
||||
if (!entry.radius) {
|
||||
// Extract from card class border-radius (best proxy for container panels)
|
||||
const cardRadiusMatch = lightHtml.match(/\.card\s*\{[^}]*border-radius:\s*([\d.]+)px/i)
|
||||
|| lightHtml.match(/\.card[^{]*\{[^}]*border-radius:\s*([\d.]+)px/i)
|
||||
if (cardRadiusMatch) {
|
||||
const px = Number.parseFloat(cardRadiusMatch[1])
|
||||
// Only accept reasonable container radii (4-20px)
|
||||
if (px >= 4 && px <= 20) {
|
||||
const rem = Math.round(px / 16 * 1000) / 1000
|
||||
entry.radius = `${rem}rem`
|
||||
changes.push(`radius=${px}px`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── error ──
|
||||
if (!entry.colors.error) {
|
||||
const error = findColor(lightVars, 'color-error', 'error', 'neg', 'danger', 'destructive', 'color-danger', 'color-destructive', 'red', 'color-red')
|
||||
if (error) {
|
||||
entry.colors.error = error
|
||||
changes.push(`error=${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── brandForeground ──
|
||||
if (!entry.colors.brandForeground) {
|
||||
const brandFg = findBrandForeground(lightHtml, entry.colors.brand)
|
||||
if (brandFg) {
|
||||
// Sanity check: must have reasonable contrast against brand (≥ 2:1)
|
||||
try {
|
||||
const cr = contrastRatio(toHsl(brandFg), toHsl(entry.colors.brand))
|
||||
if (cr >= 2) {
|
||||
entry.colors.brandForeground = brandFg
|
||||
changes.push(`brandFg=${brandFg}`)
|
||||
}
|
||||
}
|
||||
catch { /* skip unparseable */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── ring ──
|
||||
if (!entry.colors.ring) {
|
||||
const ring = findRing(lightVars)
|
||||
if (ring) {
|
||||
entry.colors.ring = ring
|
||||
changes.push(`ring=${ring}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── dark section ──
|
||||
if (!entry.dark) {
|
||||
const dark = {}
|
||||
const darkBg = findColor(darkVars, 'bg-page', 'bg', 'color-bg', 'background')
|
||||
const darkFg = findColor(darkVars, 'text-primary', 'color-foreground', 'fg', 'white')
|
||||
const darkSurface = findColor(darkVars, 'bg-card', 'surface', 'bg-surface', 'bg-panel', 'card')
|
||||
const darkSecondary = findColor(darkVars, 'text-secondary', 'secondary', 'color-secondary')
|
||||
const darkBorder = findColor(darkVars, 'border-color', 'border', 'border-subtle', 'color-border')
|
||||
const darkError = findColor(darkVars, 'color-error', 'error', 'neg', 'danger', 'destructive')
|
||||
const darkRing = findRing(darkVars)
|
||||
|
||||
if (darkBg)
|
||||
dark.background = darkBg
|
||||
if (darkFg)
|
||||
dark.foreground = darkFg
|
||||
if (darkSurface)
|
||||
dark.surface = darkSurface
|
||||
if (darkSecondary)
|
||||
dark.secondaryText = darkSecondary
|
||||
if (darkBorder)
|
||||
dark.border = darkBorder
|
||||
if (darkError && darkError !== entry.colors.error)
|
||||
dark.error = darkError
|
||||
if (darkRing)
|
||||
dark.ring = darkRing
|
||||
|
||||
// Only add dark section if we got at least background
|
||||
if (dark.background) {
|
||||
entry.dark = dark
|
||||
changes.push(`dark={bg:${dark.background} fg:${dark.foreground || '-'} surface:${dark.surface || '-'} border:${dark.border || '-'}}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
const native = entry.native === 'dark' ? '◐' : '◑'
|
||||
console.log(` ${native} ${entry.id.padEnd(15)} ${changes.join(', ')}`)
|
||||
updated++
|
||||
}
|
||||
else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${updated} updated, ${skipped} skipped`)
|
||||
|
||||
if (!dryRun) {
|
||||
writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`)
|
||||
console.log(`Written → ${registryPath}`)
|
||||
}
|
||||
else {
|
||||
console.log('(dry run — no files written)')
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Layered palette generator.
|
||||
*
|
||||
* From a small set of key colors extracted from a DESIGN.md, derive the full
|
||||
* --ms-* token map that markstream-vue expects.
|
||||
*
|
||||
* Input (all optional except background + foreground + brand):
|
||||
* { background, foreground, brand, surface?, secondaryText?, border? }
|
||||
*
|
||||
* Output: { light: { ...tokens }, dark: { ...tokens } }
|
||||
* where each token value is a shadcn bare HSL string "H S% L%".
|
||||
*/
|
||||
|
||||
import {
|
||||
adjustLightness,
|
||||
contrastRatio,
|
||||
hslToShadcn,
|
||||
mixHsl,
|
||||
toHsl,
|
||||
} from './color.mjs'
|
||||
|
||||
// ─── Main entry ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate the full --ms-* token set from key colors + typography.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.background - Page background (hex/css)
|
||||
* @param {string} input.foreground - Primary text (hex/css)
|
||||
* @param {string} input.brand - Brand / accent color (hex/css)
|
||||
* @param {string} [input.surface] - Card / surface background
|
||||
* @param {string} [input.secondaryText] - Secondary text color
|
||||
* @param {string} [input.border] - Border color
|
||||
* @param {string} [input.error] - Error / destructive color
|
||||
* @param {string} [input.link] - Link color
|
||||
* @param {string} [input.brandForeground] - Text on brand buttons (overrides auto)
|
||||
* @param {string} [input.ring] - Focus ring color
|
||||
* @param {string} [input.info] - Info / note semantic color
|
||||
* @param {string} [input.success] - Success / tip semantic color
|
||||
* @param {string} [input.warning] - Warning semantic color
|
||||
* @param {string} [input.highlight] - Highlight / mark color
|
||||
* @param {string} [input.diffAdded] - Diff added color
|
||||
* @param {string} [input.diffRemoved] - Diff removed color
|
||||
* @param {string} [input.radius] - Default component border-radius
|
||||
* @param {object} [input.fonts] - Typography overrides
|
||||
* @param {string} [input.fonts.sans] - Sans-serif font stack
|
||||
* @param {string} [input.fonts.mono] - Monospace font stack
|
||||
* @param {string} [input.fonts.serif] - Serif font stack (for heading variants)
|
||||
* @returns {Record<string, string>} Map of token name → value
|
||||
*/
|
||||
export function generatePalette(input) {
|
||||
const bg = toHsl(input.background)
|
||||
const fg = toHsl(input.foreground)
|
||||
const brand = toHsl(input.brand)
|
||||
|
||||
const isLight = bg.l > 50
|
||||
const surface = input.surface ? toHsl(input.surface) : deriveSurface(bg, isLight)
|
||||
// User-provided values pass through unchanged (trust the designer).
|
||||
// Only derived values get contrast-corrected.
|
||||
const secondaryText = input.secondaryText
|
||||
? toHsl(input.secondaryText)
|
||||
: deriveSecondaryText(fg, bg, isLight)
|
||||
const border = input.border
|
||||
? toHsl(input.border)
|
||||
: deriveBorder(bg, fg, isLight)
|
||||
const error = input.error ? toHsl(input.error) : deriveError(isLight)
|
||||
const link = input.link ? toHsl(input.link) : deriveLink(brand, bg, fg, isLight)
|
||||
|
||||
// ── Bridge tokens (shadcn-compatible) ──
|
||||
// muted must be computed before semantic colors (used for admonition header bg)
|
||||
const muted = deriveMuted(bg, fg, isLight)
|
||||
const mutedFg = secondaryText
|
||||
const secondary = surface
|
||||
const secondaryFg = isLight
|
||||
? adjustLightness(fg, 10)
|
||||
: adjustLightness(fg, -10)
|
||||
const accent = deriveAccent(bg, brand, isLight)
|
||||
const accentFg = isLight
|
||||
? adjustLightness(fg, 5)
|
||||
: adjustLightness(fg, -5)
|
||||
const primary = brand
|
||||
const primaryFg = input.brandForeground
|
||||
? toHsl(input.brandForeground)
|
||||
: derivePrimaryForeground(brand)
|
||||
const destructive = error
|
||||
const destructiveFg = derivePrimaryForeground(error)
|
||||
const ring = input.ring
|
||||
? toHsl(input.ring)
|
||||
: fg
|
||||
const popover = bg
|
||||
const popoverFg = fg
|
||||
|
||||
// ── Extension tokens (markstream-specific) ──
|
||||
// Signal & content colors are only emitted when explicitly provided.
|
||||
// When absent, the library defaults in index.css apply.
|
||||
const extensionTokens = {}
|
||||
|
||||
if (input.info) {
|
||||
const c = toHsl(input.info)
|
||||
extensionTokens.info = c
|
||||
extensionTokens['info-foreground'] = derivePrimaryForeground(c)
|
||||
}
|
||||
if (input.success) {
|
||||
const c = toHsl(input.success)
|
||||
extensionTokens.success = c
|
||||
extensionTokens['success-foreground'] = derivePrimaryForeground(c)
|
||||
}
|
||||
if (input.warning) {
|
||||
const c = toHsl(input.warning)
|
||||
extensionTokens.warning = c
|
||||
extensionTokens['warning-foreground'] = derivePrimaryForeground(c)
|
||||
}
|
||||
if (input.diffAdded)
|
||||
extensionTokens['diff-added'] = toHsl(input.diffAdded)
|
||||
if (input.diffRemoved)
|
||||
extensionTokens['diff-removed'] = toHsl(input.diffRemoved)
|
||||
if (input.highlight) {
|
||||
const c = toHsl(input.highlight)
|
||||
extensionTokens.highlight = c
|
||||
extensionTokens['highlight-foreground'] = derivePrimaryForeground(c)
|
||||
}
|
||||
|
||||
const colorTokens = toTokenMap({
|
||||
// Bridge
|
||||
'background': bg,
|
||||
'foreground': fg,
|
||||
'muted': muted,
|
||||
'muted-foreground': mutedFg,
|
||||
'secondary': secondary,
|
||||
'secondary-foreground': secondaryFg,
|
||||
'accent': accent,
|
||||
'accent-foreground': accentFg,
|
||||
'primary': primary,
|
||||
'primary-foreground': primaryFg,
|
||||
'destructive': destructive,
|
||||
'destructive-foreground': destructiveFg,
|
||||
'border': border,
|
||||
'ring': ring,
|
||||
'popover': popover,
|
||||
'popover-foreground': popoverFg,
|
||||
'radius': null, // not a color — handled separately
|
||||
|
||||
// Extension (pass-through only)
|
||||
...extensionTokens,
|
||||
'link': link,
|
||||
})
|
||||
|
||||
// Font tokens (not color, pass through as-is)
|
||||
const fonts = input.fonts || {}
|
||||
if (fonts.sans)
|
||||
colorTokens['font-sans'] = fonts.sans
|
||||
if (fonts.mono)
|
||||
colorTokens['font-mono'] = fonts.mono
|
||||
if (fonts.serif)
|
||||
colorTokens['font-serif'] = fonts.serif
|
||||
|
||||
// Radius token
|
||||
if (input.radius)
|
||||
colorTokens.radius = input.radius
|
||||
|
||||
return colorTokens
|
||||
}
|
||||
|
||||
// ─── Core contrast helper ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Iteratively adjust lightness until the color meets a target contrast ratio
|
||||
* against a reference color. Preserves hue and saturation.
|
||||
*
|
||||
* @param {object} color - HSL color to adjust
|
||||
* @param {object} reference - HSL color to measure contrast against
|
||||
* @param {number} target - Minimum contrast ratio (e.g. 4.5 for AA)
|
||||
* @param {boolean} darken - Direction: true = make darker, false = make lighter
|
||||
* @returns {object} Adjusted HSL color guaranteed to meet target (or at limit)
|
||||
*/
|
||||
function ensureContrast(color, reference, target, darken) {
|
||||
// Add small epsilon to avoid floating-point near-misses at the boundary
|
||||
const effectiveTarget = target + 0.02
|
||||
// Coarse pass: step by 1 to find the region
|
||||
let c = { ...color }
|
||||
const coarseStep = darken ? -1 : 1
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (contrastRatio(c, reference) >= effectiveTarget)
|
||||
return c
|
||||
c = adjustLightness(c, coarseStep)
|
||||
if (c.l <= 0 || c.l >= 100)
|
||||
return c
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ─── Derivation helpers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive surface color: slightly offset from background.
|
||||
*/
|
||||
function deriveSurface(bg, isLight) {
|
||||
return isLight
|
||||
? adjustLightness(bg, -3)
|
||||
: adjustLightness(bg, 5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive muted background: between bg and fg, close to bg side.
|
||||
*/
|
||||
function deriveMuted(bg, fg, isLight) {
|
||||
return isLight
|
||||
? adjustLightness(bg, -4)
|
||||
: adjustLightness(bg, 8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive secondary text: mix fg/bg, then iterate to guarantee ≥ 4.5:1 against bg.
|
||||
*/
|
||||
function deriveSecondaryText(fg, bg, isLight) {
|
||||
const seed = mixHsl(fg, bg, 0.35)
|
||||
return ensureContrast(seed, bg, 4.5, isLight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive border: must meet WCAG non-text contrast (≥ 3:1) against background.
|
||||
* Starts from a subtle mix, then pushes until 3:1 is reached.
|
||||
*/
|
||||
function deriveBorder(bg, fg, isLight) {
|
||||
const seed = isLight ? mixHsl(bg, fg, 0.1) : mixHsl(bg, fg, 0.12)
|
||||
return ensureContrast(seed, bg, 3, isLight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive accent: bg tinted with brand, subtle.
|
||||
*/
|
||||
function deriveAccent(bg, brand, isLight) {
|
||||
return isLight
|
||||
? mixHsl(bg, brand, 0.06)
|
||||
: mixHsl(bg, brand, 0.1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive error color (default red if not provided).
|
||||
*/
|
||||
function deriveError(isLight) {
|
||||
return isLight
|
||||
? { h: 0, s: 84.2, l: 60.2 }
|
||||
: { h: 0, s: 62.8, l: 30.6 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive link color from brand. Targets:
|
||||
* 1. ≥ 4.5:1 against background (AA text — hard requirement)
|
||||
* 2. ≥ 3:1 against foreground (color-only link distinguishability — best-effort)
|
||||
*
|
||||
* Sweeps H/S/L to find the best feasible link. If both constraints are
|
||||
* mathematically impossible (e.g. very dark fg + very light bg with blue hue),
|
||||
* prioritizes bg contrast and maximizes fg contrast within that constraint.
|
||||
*/
|
||||
function deriveLink(brand, bg, fg, isLight) {
|
||||
const h = brand.h
|
||||
const isBlueish = h >= 180 && h <= 290
|
||||
const seedH = isBlueish ? brand.h : 212
|
||||
const seedS = isBlueish ? Math.min(brand.s, 100) : 100
|
||||
const seedL = isLight ? 38 : 68
|
||||
|
||||
// Sweep S and L. Two-tier strategy:
|
||||
// Tier 1: find candidates meeting BOTH bg≥4.5 AND fg≥3, pick closest to seed.
|
||||
// Tier 2: if none found, pick the L closest to seed that meets bg≥4.5.
|
||||
// (fg shortfall will be honestly reported by validateContrast)
|
||||
let tier1 = null
|
||||
let tier1Dist = Infinity
|
||||
let tier2 = null
|
||||
let tier2Dist = Infinity
|
||||
|
||||
// Always sweep nearby hues — even blue brands may need a hue shift for contrast
|
||||
const hueSet = new Set([seedH])
|
||||
for (let dh = -15; dh <= 15; dh += 5) {
|
||||
hueSet.add(((seedH + dh) % 360 + 360) % 360)
|
||||
}
|
||||
const hues = [...hueSet]
|
||||
|
||||
for (const sh of hues) {
|
||||
for (let s = seedS; s >= 40; s -= 5) {
|
||||
for (let l = 0; l <= 100; l++) {
|
||||
const candidate = { h: sh, s, l }
|
||||
const crBg = contrastRatio(candidate, bg)
|
||||
if (crBg < 4.5)
|
||||
continue
|
||||
|
||||
const dist = Math.abs(l - seedL) + Math.abs(s - seedS) * 0.1 + Math.abs(sh - seedH) * 0.2
|
||||
|
||||
// Tier 2: any bg-passing candidate
|
||||
if (dist < tier2Dist) {
|
||||
tier2Dist = dist
|
||||
tier2 = candidate
|
||||
}
|
||||
|
||||
// Tier 1: also meets fg≥3
|
||||
const crFg = contrastRatio(candidate, fg)
|
||||
if (crFg >= 3 && dist < tier1Dist) {
|
||||
tier1Dist = dist
|
||||
tier1 = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tier1 || tier2 || ensureContrast({ h: seedH, s: seedS, l: seedL }, bg, 4.5, isLight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive primary foreground: white or dark text depending on brand lightness.
|
||||
* Tries tinted white/dark first for warmth, falls back to pure white/black.
|
||||
* Always picks whichever option has the best contrast.
|
||||
*/
|
||||
function derivePrimaryForeground(brand) {
|
||||
const candidates = [
|
||||
{ h: 210, s: 40, l: 98 }, // tinted white
|
||||
{ h: 222.2, s: 47.4, l: 11.2 }, // tinted dark
|
||||
{ h: 0, s: 0, l: 100 }, // pure white
|
||||
{ h: 0, s: 0, l: 0 }, // pure black
|
||||
]
|
||||
|
||||
let best = candidates[0]
|
||||
let bestCr = 0
|
||||
for (const c of candidates) {
|
||||
const cr = contrastRatio(c, brand)
|
||||
if (cr > bestCr) {
|
||||
bestCr = cr
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ─── Output formatting ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert named HSL objects to { tokenName: "H S% L%" } map.
|
||||
*/
|
||||
function toTokenMap(obj) {
|
||||
const result = {}
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
if (val === null)
|
||||
continue
|
||||
result[key] = hslToShadcn(val)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Token metadata (for documentation / validation) ────────────────
|
||||
|
||||
/**
|
||||
* All bridge token names that map to shadcn variables.
|
||||
*/
|
||||
export const BRIDGE_TOKENS = [
|
||||
'background',
|
||||
'foreground',
|
||||
'muted',
|
||||
'muted-foreground',
|
||||
'secondary',
|
||||
'secondary-foreground',
|
||||
'accent',
|
||||
'accent-foreground',
|
||||
'primary',
|
||||
'primary-foreground',
|
||||
'destructive',
|
||||
'destructive-foreground',
|
||||
'border',
|
||||
'ring',
|
||||
'popover',
|
||||
'popover-foreground',
|
||||
]
|
||||
|
||||
/**
|
||||
* All extension token names specific to markstream-vue.
|
||||
* Signal & content tokens are optional — only emitted when the registry provides them.
|
||||
* When absent, the library defaults in src/index.css apply.
|
||||
*/
|
||||
export const EXTENSION_TOKENS = [
|
||||
'info',
|
||||
'info-foreground',
|
||||
'success',
|
||||
'success-foreground',
|
||||
'warning',
|
||||
'warning-foreground',
|
||||
'diff-added',
|
||||
'diff-removed',
|
||||
'highlight',
|
||||
'highlight-foreground',
|
||||
'link',
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Theme Registry",
|
||||
"description": "Curated design tokens extracted from DESIGN.md files for markstream-vue theme generation.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "source", "colors"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Kebab-case identifier, used as CSS data-theme value and filename",
|
||||
"pattern": "^[a-z0-9-]+$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name (e.g. 'Claude', 'Linear')"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Origin DESIGN.md directory name in awesome-design-md"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["ai", "devtool", "infra", "design", "fintech", "enterprise"],
|
||||
"description": "Classification for documentation"
|
||||
},
|
||||
"native": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"],
|
||||
"description": "The scheme the source design is built around. The opposite scheme will be auto-generated."
|
||||
},
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"required": ["background", "foreground", "brand"],
|
||||
"properties": {
|
||||
"background": { "type": "string", "description": "Page background" },
|
||||
"foreground": { "type": "string", "description": "Primary text color" },
|
||||
"brand": { "type": "string", "description": "Primary brand / accent / CTA color" },
|
||||
"surface": { "type": "string", "description": "Card / elevated surface background" },
|
||||
"secondaryText": { "type": "string", "description": "Secondary / muted text color" },
|
||||
"border": { "type": "string", "description": "Default border color" },
|
||||
"error": { "type": "string", "description": "Error / destructive color" },
|
||||
"link": { "type": "string", "description": "Link text color" },
|
||||
"brandForeground": { "type": "string", "description": "Text on brand/CTA buttons (overrides auto contrast pick)" },
|
||||
"ring": { "type": "string", "description": "Focus ring color" },
|
||||
"info": { "type": "string", "description": "Info / note semantic color" },
|
||||
"success": { "type": "string", "description": "Success / tip semantic color" },
|
||||
"warning": { "type": "string", "description": "Warning semantic color" },
|
||||
"highlight": { "type": "string", "description": "Highlight / mark color" },
|
||||
"diffAdded": { "type": "string", "description": "Diff added indicator color" },
|
||||
"diffRemoved": { "type": "string", "description": "Diff removed indicator color" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"radius": {
|
||||
"type": "string",
|
||||
"description": "Default component border-radius (CSS value, e.g. '0.5rem', '6px')"
|
||||
},
|
||||
"dark": {
|
||||
"type": "object",
|
||||
"description": "Explicit dark-scheme colors. When dark.background is present, used instead of auto-generating opposite scheme.",
|
||||
"properties": {
|
||||
"background": { "type": "string" },
|
||||
"foreground": { "type": "string" },
|
||||
"brand": { "type": "string" },
|
||||
"surface": { "type": "string" },
|
||||
"secondaryText": { "type": "string" },
|
||||
"border": { "type": "string" },
|
||||
"error": { "type": "string" },
|
||||
"link": { "type": "string" },
|
||||
"brandForeground": { "type": "string" },
|
||||
"ring": { "type": "string" },
|
||||
"info": { "type": "string" },
|
||||
"success": { "type": "string" },
|
||||
"warning": { "type": "string" },
|
||||
"highlight": { "type": "string" },
|
||||
"diffAdded": { "type": "string" },
|
||||
"diffRemoved": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"fonts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sans": { "type": "string", "description": "Sans-serif font stack (CSS font-family value)" },
|
||||
"mono": { "type": "string", "description": "Monospace font stack" },
|
||||
"serif": { "type": "string", "description": "Serif font stack (for heading variants)" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extracts language-relevant SVGs from the material-icon-theme npm package
|
||||
* and writes them to src/icon-themes/material/svg/.
|
||||
*
|
||||
* Usage: node scripts/vendor-material-icons.mjs
|
||||
*/
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
const SOURCE = join(ROOT, 'node_modules/material-icon-theme/icons')
|
||||
const TARGET = join(ROOT, 'src/icon-themes/material/svg')
|
||||
|
||||
// Mapping: our language identifier → material-icon-theme filename (without .svg)
|
||||
const ICON_MAP = {
|
||||
// Core languages (~27)
|
||||
javascript: 'javascript',
|
||||
typescript: 'typescript',
|
||||
jsx: 'react',
|
||||
tsx: 'react_ts',
|
||||
html: 'html',
|
||||
css: 'css',
|
||||
scss: 'sass',
|
||||
json: 'json',
|
||||
python: 'python',
|
||||
ruby: 'ruby',
|
||||
go: 'go',
|
||||
java: 'java',
|
||||
kotlin: 'kotlin',
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
csharp: 'csharp',
|
||||
php: 'php',
|
||||
shell: 'console',
|
||||
powershell: 'powershell',
|
||||
sql: 'database',
|
||||
yaml: 'yaml',
|
||||
markdown: 'markdown',
|
||||
xml: 'xml',
|
||||
rust: 'rust',
|
||||
vue: 'vue',
|
||||
text: 'document',
|
||||
plain: 'document',
|
||||
|
||||
// Extended languages (~33)
|
||||
ada: 'ada',
|
||||
applescript: 'applescript',
|
||||
assembly: 'assembly',
|
||||
clojure: 'clojure',
|
||||
cobol: 'cobol',
|
||||
crystal: 'crystal',
|
||||
dart: 'dart',
|
||||
dlang: 'd',
|
||||
docker: 'docker',
|
||||
elixir: 'elixir',
|
||||
erlang: 'erlang',
|
||||
fortran: 'fortran',
|
||||
groovy: 'groovy',
|
||||
haskell: 'haskell',
|
||||
julia: 'julia',
|
||||
lisp: 'lisp',
|
||||
lua: 'lua',
|
||||
nim: 'nim',
|
||||
objectivec: 'c',
|
||||
objectivecpp: 'cpp',
|
||||
ocaml: 'ocaml',
|
||||
perl: 'perl',
|
||||
prolog: 'prolog',
|
||||
r: 'r',
|
||||
scala: 'scala',
|
||||
solidity: 'solidity',
|
||||
svelte: 'svelte',
|
||||
svg: 'xml',
|
||||
swift: 'swift',
|
||||
terraform: 'terraform',
|
||||
vbnet: 'csharp',
|
||||
mermaid: 'markdown', // no dedicated mermaid icon, use markdown
|
||||
}
|
||||
|
||||
// Ensure target directory exists
|
||||
mkdirSync(TARGET, { recursive: true })
|
||||
|
||||
let copied = 0
|
||||
let missing = 0
|
||||
|
||||
for (const [lang, materialName] of Object.entries(ICON_MAP)) {
|
||||
const srcFile = join(SOURCE, `${materialName}.svg`)
|
||||
const dstFile = join(TARGET, `${lang}.svg`)
|
||||
|
||||
if (!existsSync(srcFile)) {
|
||||
console.warn(` MISS: ${lang} → ${materialName}.svg not found`)
|
||||
missing++
|
||||
continue
|
||||
}
|
||||
|
||||
// Read SVG and ensure width/height attributes exist for proper inline rendering
|
||||
const svg = readFileSync(srcFile, 'utf-8')
|
||||
writeFileSync(dstFile, svg)
|
||||
copied++
|
||||
}
|
||||
|
||||
// Also copy a generic code icon for fallback
|
||||
const fallbackSrc = join(SOURCE, 'console.svg')
|
||||
const fallbackDst = join(TARGET, '_fallback.svg')
|
||||
if (existsSync(fallbackSrc)) {
|
||||
let svg = readFileSync(fallbackSrc, 'utf-8')
|
||||
// Only check the <svg> opening tag for width attribute
|
||||
const svgTag = svg.match(/<svg[^>]*>/)?.[0] ?? ''
|
||||
if (!svgTag.includes(' width=')) {
|
||||
svg = svg.replace('<svg ', '<svg width="16" height="16" ')
|
||||
}
|
||||
writeFileSync(fallbackDst, svg)
|
||||
}
|
||||
|
||||
// Copy license
|
||||
const licenseSrc = join(ROOT, 'node_modules/material-icon-theme/LICENSE')
|
||||
if (existsSync(licenseSrc)) {
|
||||
copyFileSync(licenseSrc, join(dirname(TARGET), 'LICENSE-material-icon-theme'))
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${copied} icons copied, ${missing} missing`)
|
||||
console.log(`Target: ${TARGET}`)
|
||||
Reference in New Issue
Block a user