Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1 @@
|
||||
share-codes.txt
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* after-pack.mjs — electron-builder afterPack hook.
|
||||
*
|
||||
* Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via
|
||||
* rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed build
|
||||
* — first install, `hermes desktop`, the installer's --update rebuild, and a
|
||||
* dev's manual `npm run pack` — so the branded exe can never silently revert
|
||||
* to the stock "Electron" icon/name (the bug when the stamp lived only in
|
||||
* install.ps1, which the update path doesn't use).
|
||||
*
|
||||
* Windows-only: rcedit edits PE resources, irrelevant on macOS/Linux where the
|
||||
* app identity comes from the bundle Info.plist / desktop entry. AITURK builds
|
||||
* fail if executable branding cannot be applied.
|
||||
*
|
||||
* electron-builder passes a context with:
|
||||
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
|
||||
* - appOutDir: the unpacked app directory for this target
|
||||
* - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes')
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
import { stampExeIdentity } from './set-exe-identity.mjs'
|
||||
|
||||
export default async function afterPack(context) {
|
||||
if (context.electronPlatformName !== 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const productName = context.packager?.appInfo?.productFilename || 'Hermes'
|
||||
const configuredExe = path.join(context.appOutDir, 'AITURK-IDE.exe')
|
||||
const exe = existsSync(configuredExe) ? configuredExe : path.join(context.appOutDir, `${productName}.exe`)
|
||||
const desktopRoot = path.resolve(import.meta.dirname, '..')
|
||||
|
||||
try {
|
||||
await stampExeIdentity(exe, desktopRoot)
|
||||
} catch (err) {
|
||||
throw new Error(`AITURK executable branding failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
|
||||
//
|
||||
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
|
||||
// If the `build` step (tsc -b && vite build) fails but packaging proceeds
|
||||
// anyway — a stale checkout that fails typecheck, an interrupted vite build,
|
||||
// or npm not short-circuiting `&&` in some shells — electron-builder happily
|
||||
// packages an app with an empty or missing `dist/`. The result launches but
|
||||
// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why.
|
||||
//
|
||||
// This runs at the tail of `build`, after vite build, so any packaging path
|
||||
// inherits it. It fails loud and early instead of shipping a broken bundle.
|
||||
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
|
||||
|
||||
import { existsSync, readFileSync, statSync, readdirSync } from "fs"
|
||||
import { join, resolve } from "path"
|
||||
import { isMain } from "./utils.mjs"
|
||||
|
||||
const ROUTER_CONTEXT_ERROR = "may be used only in the context of a"
|
||||
|
||||
// @tanstack/react-query carries module-level React context (QueryClientContext).
|
||||
// The entry's QueryClientProvider and every lazy chunk's useQuery must share ONE
|
||||
// runtime instance; if a build ever emits a second copy, the provider's context
|
||||
// is invisible to the other copy and useQuery throws "No QueryClient set" — the
|
||||
// packaged app error-boundaries on launch (#95560). Same single-instance
|
||||
// invariant as the react-router check above, same failure class.
|
||||
const QUERY_CLIENT_CONTEXT_ERROR = "No QueryClient set, use QueryClientProvider to set one"
|
||||
|
||||
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
|
||||
// Kept side-effect-free so it can be unit tested without spawning a process.
|
||||
export function checkDistBuilt(distDir) {
|
||||
if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
|
||||
return { ok: false, error: `no dist directory at ${distDir}` }
|
||||
}
|
||||
|
||||
const indexHtml = join(distDir, "index.html")
|
||||
if (!existsSync(indexHtml) || !statSync(indexHtml).isFile()) {
|
||||
return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }
|
||||
}
|
||||
if (statSync(indexHtml).size === 0) {
|
||||
return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }
|
||||
}
|
||||
|
||||
// index.html alone isn't enough — vite emits hashed JS into dist/assets.
|
||||
// An index.html with no script bundle still blank-pages.
|
||||
const assetsDir = join(distDir, "assets")
|
||||
const hasAssets =
|
||||
existsSync(assetsDir) &&
|
||||
statSync(assetsDir).isDirectory() &&
|
||||
readdirSync(assetsDir).some(name => name.endsWith(".js"))
|
||||
if (!hasAssets) {
|
||||
return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }
|
||||
}
|
||||
|
||||
const routerContextAssets = readdirSync(assetsDir)
|
||||
.filter(name => name.endsWith(".js"))
|
||||
.filter(name => readFileSync(join(assetsDir, name), "utf8").includes(ROUTER_CONTEXT_ERROR))
|
||||
|
||||
if (routerContextAssets.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `react-router context invariant found in multiple JS assets: ${routerContextAssets.join(", ")}`
|
||||
}
|
||||
}
|
||||
|
||||
const queryClientContextAssets = readdirSync(assetsDir)
|
||||
.filter(name => name.endsWith(".js"))
|
||||
.filter(name => readFileSync(join(assetsDir, name), "utf8").includes(QUERY_CLIENT_CONTEXT_ERROR))
|
||||
|
||||
if (queryClientContextAssets.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`@tanstack/react-query context invariant found in multiple JS assets: ` +
|
||||
`${queryClientContextAssets.join(", ")} — duplicate react-query runtimes make the ` +
|
||||
`QueryClientProvider's context invisible to useQuery in other chunks (` +
|
||||
`"No QueryClient set" on launch, #95560)`
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const desktopRoot = resolve(import.meta.dirname, "..")
|
||||
const distDir = join(desktopRoot, "dist")
|
||||
const result = checkDistBuilt(distDir)
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`\n✗ assert-dist-built: ${result.error}`)
|
||||
console.error(" The renderer bundle is missing or incomplete, so packaging")
|
||||
console.error(" would produce an app that launches to a blank page.")
|
||||
console.error(" Re-run the build and check the tsc/vite output above for the")
|
||||
console.error(" real failure, then package again:")
|
||||
console.error(` cd ${desktopRoot} && npm run build\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("✓ assert-dist-built: dist/index.html + assets present")
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
main()
|
||||
}
|
||||
|
||||
export default { checkDistBuilt }
|
||||
@@ -0,0 +1,164 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { checkDistBuilt } from '../scripts/assert-dist-built.mjs'
|
||||
|
||||
function makeDist(extra) {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
|
||||
const distDir = path.join(tempRoot, 'dist')
|
||||
fs.mkdirSync(distDir, { recursive: true })
|
||||
if (extra) extra(distDir)
|
||||
return { tempRoot, distDir }
|
||||
}
|
||||
|
||||
function writeRouterAsset(distDir, name) {
|
||||
fs.writeFileSync(
|
||||
path.join(distDir, 'assets', name),
|
||||
`throw new Error('may be used only in the context of a <Router>')`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
function writeQueryClientAsset(distDir, name) {
|
||||
fs.writeFileSync(
|
||||
path.join(distDir, 'assets', name),
|
||||
`throw new Error('No QueryClient set, use QueryClientProvider to set one')`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html><div id=root></div>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
|
||||
})
|
||||
try {
|
||||
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when the dist directory is absent', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
|
||||
try {
|
||||
const result = checkDistBuilt(path.join(tempRoot, 'dist'))
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /no dist directory/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when index.html is missing', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
|
||||
})
|
||||
try {
|
||||
const result = checkDistBuilt(distDir)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /index\.html is missing/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when index.html is empty', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
|
||||
})
|
||||
try {
|
||||
const result = checkDistBuilt(distDir)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /index\.html is empty/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when assets/ has no JS bundle', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
// CSS only, no JS — still a blank page at runtime.
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8')
|
||||
})
|
||||
try {
|
||||
const result = checkDistBuilt(distDir)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /no built JS bundle/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt passes when the Router context invariant is in one JS asset', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
writeRouterAsset(d, 'vendor-react-abc123.js')
|
||||
fs.writeFileSync(path.join(d, 'assets', 'command-def456.js'), 'console.log(1)', 'utf8')
|
||||
})
|
||||
try {
|
||||
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when the Router context invariant is in multiple JS assets', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
writeRouterAsset(d, 'vendor-react-abc123.js')
|
||||
writeRouterAsset(d, 'command-def456.js')
|
||||
})
|
||||
try {
|
||||
const result = checkDistBuilt(distDir)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /react-router context invariant found in multiple JS assets/)
|
||||
assert.match(result.error, /vendor-react-abc123\.js/)
|
||||
assert.match(result.error, /command-def456\.js/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt passes when the QueryClient context invariant is in one JS asset', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
writeQueryClientAsset(d, 'vendor-react-abc123.js')
|
||||
fs.writeFileSync(path.join(d, 'assets', 'command-def456.js'), 'console.log(1)', 'utf8')
|
||||
})
|
||||
try {
|
||||
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when the QueryClient context invariant is in multiple JS assets (#95560)', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
writeQueryClientAsset(d, 'vendor-react-abc123.js')
|
||||
writeQueryClientAsset(d, 'session-list-density-def456.js')
|
||||
})
|
||||
try {
|
||||
const result = checkDistBuilt(distDir)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /@tanstack\/react-query context invariant found in multiple JS assets/)
|
||||
assert.match(result.error, /vendor-react-abc123\.js/)
|
||||
assert.match(result.error, /session-list-density-def456\.js/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
// Build-time guard: refuse to start a build the installed tree cannot finish.
|
||||
//
|
||||
// The desktop workspace's dependencies are hoisted to the repo-root
|
||||
// `node_modules`, so a root install that only covers *part* of the workspace
|
||||
// graph leaves this app importable-looking but unbuildable. The guard exists to
|
||||
// turn that into one actionable line ("run npm ci from the repo root") instead
|
||||
// of a failure deep inside vite.
|
||||
//
|
||||
// It runs from `prebuild`, ahead of `npm run clean`, so a tree that cannot
|
||||
// build is rejected before the build starts deleting its own outputs. `build`
|
||||
// re-runs it for anyone invoking the build steps directly; the check is pure
|
||||
// filesystem lookups, so paying for it twice costs nothing.
|
||||
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import { createRequire } from "module"
|
||||
import { resolve, join, dirname } from "path"
|
||||
import { isMain } from "./utils.mjs"
|
||||
|
||||
// Packages the build *consumes*, as opposed to merely declares. Each one is
|
||||
// load-bearing for a distinct build step, and each one has been observed
|
||||
// missing from a partial root install:
|
||||
//
|
||||
// vite — bundles the renderer (`vite build`).
|
||||
// katex — `src/styles.css` imports `katex/dist/katex.min.css`, so
|
||||
// the CSS transform fails before a single chunk is emitted.
|
||||
// electron — the runtime electron-builder packages; without it `pack`
|
||||
// cannot produce an unpacked app at all.
|
||||
// electron-builder — the packager `npm run builder` shells out to.
|
||||
//
|
||||
// Checking only `vite` (the original guard) passes a tree missing any of the
|
||||
// others, which is how an incomplete install reached `vite build` and died on
|
||||
// an unresolved `katex/dist/katex.min.css` with no hint that the install — not
|
||||
// the source — was at fault (#86443).
|
||||
//
|
||||
// These four are the documented floor — always checked, even when the app's
|
||||
// package.json cannot be read. The full class is wider: EVERY non-optional
|
||||
// package the workspace manifest declares is something the build may import
|
||||
// (`vite.config.ts` pulls `@rolldown/plugin-babel`, `@vitejs/plugin-react`,
|
||||
// `@tailwindcss/vite`; `bundle-electron-main.mjs` pulls `esbuild`; the renderer
|
||||
// imports the rest). A hand-maintained list drifts the moment a new import
|
||||
// lands, so `checkRootInstall` unions the floor with the manifest's declared
|
||||
// `dependencies` + `devDependencies` — a partial install is refused whichever
|
||||
// package it happened to drop. `optionalDependencies` are excluded by design:
|
||||
// npm legitimately skips them (platform-gated natives like `get-windows`).
|
||||
const BUILD_CRITICAL_PACKAGES = ["vite", "katex", "electron", "electron-builder"]
|
||||
export { BUILD_CRITICAL_PACKAGES }
|
||||
|
||||
// Resolve the way Node's own lookup does — walk `node_modules` upward — rather
|
||||
// than through `require.resolve`. A package whose `exports` map does not expose
|
||||
// `./package.json` is not resolvable by path even when correctly installed, and
|
||||
// that must not read as "missing". Scoped names (`@scope/name`) are a nested
|
||||
// directory under `node_modules`, which `join` handles.
|
||||
function packageIsInstalled(name, fromDir) {
|
||||
let dir = fromDir
|
||||
for (;;) {
|
||||
if (existsSync(join(dir, "node_modules", name, "package.json"))) return true
|
||||
const parent = dirname(dir)
|
||||
if (parent === dir) return false
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// Every package the workspace manifest at `appDir` declares as required
|
||||
// (`dependencies` + `devDependencies`; never `optionalDependencies`). An
|
||||
// unreadable or malformed manifest yields [] — the floor still applies, and
|
||||
// the build's own manifest read fails loudly on its own.
|
||||
export function requiredPackages(appDir) {
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8"))
|
||||
return [
|
||||
...Object.keys(manifest.dependencies ?? {}),
|
||||
...Object.keys(manifest.devDependencies ?? {}),
|
||||
]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
|
||||
// Kept side-effect-free so it can be unit tested without spawning a process.
|
||||
export function checkRootInstall(appDir, rootDir) {
|
||||
const wanted = [...new Set([...BUILD_CRITICAL_PACKAGES, ...requiredPackages(appDir)])]
|
||||
const missing = wanted.filter(pkg => !packageIsInstalled(pkg, appDir))
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`the desktop build needs ${missing.join(", ")}, which the current install ` +
|
||||
`does not provide. A partial root install leaves the workspace looking ` +
|
||||
`present while the build cannot complete. Reinstall from the repo root: ` +
|
||||
`cd ${rootDir} && npm ci`
|
||||
}
|
||||
}
|
||||
|
||||
// `vite.config.ts` aliases react/react-dom to whatever this workspace resolves,
|
||||
// and React refuses to run when the two come from different installed copies
|
||||
// ("Minified React error #527" — it throws before the first paint, so the app
|
||||
// window stays blank). npm stays silent about the split because the hoisted
|
||||
// react still satisfies react-dom's caret peer range. Fail the build loudly
|
||||
// instead of shipping a white screen.
|
||||
const requireFromApp = createRequire(join(appDir, "package.json"))
|
||||
const installedVersion = pkg =>
|
||||
JSON.parse(readFileSync(requireFromApp.resolve(`${pkg}/package.json`), "utf8")).version
|
||||
|
||||
let react
|
||||
let reactDom
|
||||
try {
|
||||
react = installedVersion("react")
|
||||
reactDom = installedVersion("react-dom")
|
||||
} catch (err) {
|
||||
// Both are in BUILD_CRITICAL_PACKAGES' spirit but not its list: they are
|
||||
// checked by version, and an unreadable package.json is a broken install
|
||||
// rather than an absent one. Report it as such instead of throwing.
|
||||
return {
|
||||
ok: false,
|
||||
error: `could not read the installed react/react-dom versions (${err.message}). Reinstall from the repo root: cd ${rootDir} && npm ci`
|
||||
}
|
||||
}
|
||||
|
||||
if (react !== reactDom) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`react@${react} / react-dom@${reactDom} version mismatch — React would fail ` +
|
||||
`with error #527 and render a blank window. Pin both to the same version ` +
|
||||
`in ${join(appDir, "package.json")}, then reinstall: cd ${rootDir} && npm ci`
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const app = resolve(import.meta.dirname, "..")
|
||||
const root = resolve(app, "..", "..")
|
||||
const result = checkRootInstall(app, root)
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`✗ assert-root-install: ${result.error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
main()
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { BUILD_CRITICAL_PACKAGES as BUILD_CRITICAL, checkRootInstall, requiredPackages } from '../scripts/assert-root-install.mjs'
|
||||
|
||||
// Build a throwaway repo shaped like this one: an app workspace whose
|
||||
// dependencies are hoisted to the repo root, which is what the guard walks.
|
||||
// `manifest` is merged into the app's package.json so tests can declare
|
||||
// dependencies the guard is expected to read.
|
||||
function makeTree({ rootPackages = BUILD_CRITICAL, react = '19.2.7', reactDom = '19.2.7', manifest = {} } = {}) {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-root-'))
|
||||
const appDir = path.join(tempRoot, 'apps', 'desktop')
|
||||
fs.mkdirSync(appDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'desktop', ...manifest }), 'utf8')
|
||||
|
||||
const writePackage = (name, version) => {
|
||||
const dir = path.join(tempRoot, 'node_modules', name)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version }), 'utf8')
|
||||
}
|
||||
for (const name of rootPackages) writePackage(name, '1.0.0')
|
||||
if (react !== null) writePackage('react', react)
|
||||
if (reactDom !== null) writePackage('react-dom', reactDom)
|
||||
|
||||
return { tempRoot, appDir }
|
||||
}
|
||||
|
||||
test('checkRootInstall passes on a complete root install', () => {
|
||||
const { tempRoot, appDir } = makeTree()
|
||||
try {
|
||||
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// The regression this guard was widened for: the updater's partial `npm install`
|
||||
// left katex out while vite was present, so the old vite-only check passed and
|
||||
// the build died on an unresolved `katex/dist/katex.min.css` (#86443).
|
||||
test('checkRootInstall fails when katex is missing but vite is present', () => {
|
||||
const { tempRoot, appDir } = makeTree({
|
||||
rootPackages: BUILD_CRITICAL.filter(name => name !== 'katex')
|
||||
})
|
||||
try {
|
||||
const result = checkRootInstall(appDir, tempRoot)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /katex/)
|
||||
assert.match(result.error, /npm ci/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkRootInstall fails when electron is missing', () => {
|
||||
const { tempRoot, appDir } = makeTree({
|
||||
rootPackages: BUILD_CRITICAL.filter(name => name !== 'electron')
|
||||
})
|
||||
try {
|
||||
const result = checkRootInstall(appDir, tempRoot)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /electron/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkRootInstall reports every missing package at once', () => {
|
||||
const { tempRoot, appDir } = makeTree({ rootPackages: ['vite'] })
|
||||
try {
|
||||
const result = checkRootInstall(appDir, tempRoot)
|
||||
assert.equal(result.ok, false)
|
||||
for (const name of ['katex', 'electron', 'electron-builder']) {
|
||||
assert.match(result.error, new RegExp(name))
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// The original guard's only check — kept, so widening coverage cannot silently
|
||||
// drop the case it already handled.
|
||||
test('checkRootInstall still fails when vite is missing', () => {
|
||||
const { tempRoot, appDir } = makeTree({
|
||||
rootPackages: BUILD_CRITICAL.filter(name => name !== 'vite')
|
||||
})
|
||||
try {
|
||||
const result = checkRootInstall(appDir, tempRoot)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /vite/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkRootInstall fails on a react/react-dom version split', () => {
|
||||
const { tempRoot, appDir } = makeTree({ react: '19.2.7', reactDom: '19.1.0' })
|
||||
try {
|
||||
const result = checkRootInstall(appDir, tempRoot)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /#527/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// A package installed into the app's own node_modules rather than hoisted to the
|
||||
// root is still installed. The guard walks upward like Node does, so it must not
|
||||
// insist on the hoisted location.
|
||||
test('checkRootInstall accepts a package nested in the app workspace', () => {
|
||||
const { tempRoot, appDir } = makeTree({
|
||||
rootPackages: BUILD_CRITICAL.filter(name => name !== 'katex')
|
||||
})
|
||||
const nested = path.join(appDir, 'node_modules', 'katex')
|
||||
fs.mkdirSync(nested, { recursive: true })
|
||||
fs.writeFileSync(path.join(nested, 'package.json'), JSON.stringify({ name: 'katex' }), 'utf8')
|
||||
try {
|
||||
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// The class, not the four instances: the floor list is what a partial install
|
||||
// has been *seen* to drop, but any declared non-optional package can be the one
|
||||
// missing next (`vite.config.ts` imports `@rolldown/plugin-babel`, which the
|
||||
// floor never named). The guard must read the manifest so the list cannot drift
|
||||
// behind a new import.
|
||||
test('checkRootInstall fails when a declared devDependency outside the floor is missing', () => {
|
||||
const { tempRoot, appDir } = makeTree({
|
||||
manifest: { devDependencies: { '@rolldown/plugin-babel': '1.0.0', esbuild: '1.0.0' } },
|
||||
rootPackages: [...BUILD_CRITICAL, 'esbuild']
|
||||
})
|
||||
try {
|
||||
const result = checkRootInstall(appDir, tempRoot)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /@rolldown\/plugin-babel/)
|
||||
assert.doesNotMatch(result.error, /esbuild/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkRootInstall fails when a declared runtime dependency is missing', () => {
|
||||
const { tempRoot, appDir } = makeTree({
|
||||
manifest: { dependencies: { '@vscode/codicons': '1.0.0' } }
|
||||
})
|
||||
try {
|
||||
const result = checkRootInstall(appDir, tempRoot)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /@vscode\/codicons/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// npm skips optionalDependencies legitimately (platform-gated natives), so an
|
||||
// absent optional package is not a partial install.
|
||||
test('checkRootInstall ignores missing optionalDependencies', () => {
|
||||
const { tempRoot, appDir } = makeTree({
|
||||
manifest: { optionalDependencies: { 'get-windows': '9.3.0' } }
|
||||
})
|
||||
try {
|
||||
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkRootInstall passes when every declared package is installed', () => {
|
||||
const { tempRoot, appDir } = makeTree({
|
||||
manifest: { dependencies: { '@scope/pkg': '1.0.0' }, devDependencies: { esbuild: '1.0.0' } },
|
||||
rootPackages: [...BUILD_CRITICAL, '@scope/pkg', 'esbuild']
|
||||
})
|
||||
try {
|
||||
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// The floor is unconditional: a manifest the guard cannot parse must not turn
|
||||
// the check off.
|
||||
test('checkRootInstall keeps the floor when the manifest is unreadable', () => {
|
||||
const { tempRoot, appDir } = makeTree({ rootPackages: ['vite'] })
|
||||
fs.writeFileSync(path.join(appDir, 'package.json'), '{not json', 'utf8')
|
||||
try {
|
||||
assert.deepEqual(requiredPackages(appDir), [])
|
||||
const result = checkRootInstall(appDir, tempRoot)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /katex/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Desktop bundles ship precompiled renderer assets. Returning false here tells
|
||||
* electron-builder to skip the node_modules collector/install step, which
|
||||
* avoids workspace dependency graph explosions and keeps packaging
|
||||
* deterministic across environments. The Hermes Agent Python payload is no
|
||||
* longer bundled; the Electron app fetches it at first launch via
|
||||
* `install.ps1`'s stage protocol (Windows). See `electron/main.ts`.
|
||||
*/
|
||||
export default async function beforeBuild() {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* before-pack.mjs — electron-builder beforePack hook.
|
||||
*
|
||||
* Two responsibilities:
|
||||
*
|
||||
* 1. Removes any stale unpacked app directory (`appOutDir`) before
|
||||
* electron-builder stages the Electron binaries into it.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* electron-builder's final packaging step copies the stock `electron`
|
||||
* binary into `release/<platform>-unpacked/` and then renames it to the
|
||||
* product name (`Hermes`). If a PREVIOUS `npm run pack` was interrupted
|
||||
* (Ctrl-C, OOM kill, crash, full disk) the unpacked directory is left in a
|
||||
* corrupted partial state: it keeps the already-renamed `LICENSE.electron.txt`
|
||||
* and the Chromium payload (.pak/.so/icudtl.dat/chrome-sandbox) but is MISSING
|
||||
* the `electron` binary itself.
|
||||
*
|
||||
* On the next run, electron-builder sees the destination directory already
|
||||
* populated, skips re-copying the binary it thinks is present, then tries to
|
||||
* rename a `electron` file that no longer exists. The build dies with:
|
||||
*
|
||||
* ENOENT: no such file or directory, rename
|
||||
* '.../release/linux-unpacked/electron' -> '.../release/linux-unpacked/Hermes'
|
||||
*
|
||||
* This is a hard failure with no obvious cause for the user — `hermes desktop`
|
||||
* just prints "Desktop GUI build failed" and the only fix is to manually
|
||||
* `rm -rf` the release directory, which a normal user has no way to know.
|
||||
*
|
||||
* The packaging step is not idempotent across an interrupted run, so we make
|
||||
* it idempotent ourselves: wipe the target unpacked directory up front so
|
||||
* electron-builder always stages into a clean tree. This is safe — the
|
||||
* directory is a pure build artifact that electron-builder fully recreates
|
||||
* on every pack; nothing else depends on its prior contents.
|
||||
*
|
||||
* Cross-platform: the same partial-state trap exists on macOS
|
||||
* (the mac-unpacked Hermes.app bundle) and Windows (win-unpacked), so we
|
||||
* clean whatever `appOutDir` electron-builder hands us regardless of platform.
|
||||
*
|
||||
* Best-effort: a cleanup failure must never mask the real build. We log and
|
||||
* resolve rather than throw — worst case electron-builder hits the original
|
||||
* ENOENT, which is no worse than not having this hook at all.
|
||||
*
|
||||
* 2. Re-stages node-pty's native files for the ACTUAL target platform/arch
|
||||
* of this pack. `npm run build` already staged node-pty once for the
|
||||
* host machine (see scripts/stage-native-deps.mjs), which is correct for
|
||||
* single-arch builds matching the host. But electron-builder can target
|
||||
* a different arch than the host (cross-build), or pack multiple archs
|
||||
* from one `npm run build` (e.g. `dist:mac` => x64 + arm64). Only this
|
||||
* hook knows the real per-target arch, via `context.arch` /
|
||||
* `context.electronPlatformName` — so it re-stages on top of whatever
|
||||
* `npm run build` left behind, per target, right before files are read
|
||||
* for packing.
|
||||
*
|
||||
* electron-builder passes a context with:
|
||||
* - appOutDir: the unpacked app directory about to be staged
|
||||
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
|
||||
* - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal)
|
||||
*/
|
||||
import { existsSync, rmSync, renameSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Arch } from 'electron-builder'
|
||||
import { stageNodePty, stageGetWindows } from './stage-native-deps.mjs'
|
||||
|
||||
export function cleanStaleAppOutDir(appOutDir) {
|
||||
if (!appOutDir || typeof appOutDir !== 'string') {
|
||||
return false
|
||||
}
|
||||
if (!existsSync(appOutDir)) {
|
||||
return false
|
||||
}
|
||||
// Recursive + force so a half-written tree (read-only bits, partial files)
|
||||
// can't block the wipe. retry/maxRetries rides out transient EBUSY on
|
||||
// Windows where an AV/indexer may briefly hold a handle.
|
||||
rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows rollback material (#69179): before wiping the previous unpacked
|
||||
* tree, preserve it as `<appOutDir>.bak` — but ONLY when it holds the product
|
||||
* exe (i.e. it is a previously-working build, not the corrupted partial state
|
||||
* cleanStaleAppOutDir exists to remove). If the fresh pack then produces a
|
||||
* Hermes.exe that Windows can't load (truncated PE from a corrupt cached
|
||||
* Electron zip, wrong arch), the updater's integrity gate in
|
||||
* `hermes desktop --build-only` (hermes_cli/main.py
|
||||
* `_ensure_desktop_exe_launchable`) restores this .bak instead of leaving the
|
||||
* user with "This app can't run on your computer".
|
||||
*
|
||||
* Returns true when the tree was preserved (appOutDir no longer exists), false
|
||||
* when there was nothing worth preserving (caller falls through to the wipe).
|
||||
* A rename failure (AV holding a handle) also returns false — the wipe is the
|
||||
* safe fallback and matches pre-#69179 behavior exactly.
|
||||
*/
|
||||
export function preserveRollbackBackup(appOutDir, productExeName = 'Hermes.exe') {
|
||||
if (!appOutDir || typeof appOutDir !== 'string' || !existsSync(appOutDir)) {
|
||||
return false
|
||||
}
|
||||
if (!existsSync(path.join(appOutDir, productExeName))) {
|
||||
// Partial/corrupt tree (interrupted prior pack) — not rollback material.
|
||||
return false
|
||||
}
|
||||
const backupDir = `${appOutDir}.bak`
|
||||
try {
|
||||
rmSync(backupDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
renameSync(appOutDir, backupDir)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default async function beforePack(context) {
|
||||
const appOutDir = context && context.appOutDir
|
||||
const platformName = context && context.electronPlatformName
|
||||
try {
|
||||
// Windows: keep the previous working build as rollback material for the
|
||||
// post-build integrity gate (#69179) instead of destroying it. Falls
|
||||
// through to the plain wipe when the old tree is partial/corrupt or the
|
||||
// rename fails.
|
||||
const productExe = `${(context && context.packager?.appInfo?.productFilename) || 'Hermes'}.exe`
|
||||
if (platformName === 'win32' && preserveRollbackBackup(appOutDir, productExe)) {
|
||||
console.log(`[before-pack] preserved previous unpacked dir for rollback: ${appOutDir}.bak`)
|
||||
} else if (cleanStaleAppOutDir(appOutDir)) {
|
||||
console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)
|
||||
}
|
||||
} catch (err) {
|
||||
// Never fail the build over cleanup; surface why so a genuinely stuck
|
||||
// directory (permissions, mount) is still diagnosable.
|
||||
console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)
|
||||
}
|
||||
|
||||
try {
|
||||
const platform = context && context.electronPlatformName
|
||||
const archName = context && typeof context.arch === 'number' ? Arch[context.arch] : undefined
|
||||
if (platform && archName) {
|
||||
if (archName === 'universal') {
|
||||
console.warn(
|
||||
'[before-pack] target arch is "universal" — node-pty has no universal prebuild; ' +
|
||||
'staged binary will be whichever single-arch copy npm run build left behind. ' +
|
||||
'lipo-merge x64/arm64 .node files manually if you need a true universal build.'
|
||||
)
|
||||
} else {
|
||||
await stageNodePty({ platform, arch: archName })
|
||||
console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`)
|
||||
}
|
||||
// The macOS helper is universal, while Windows bindings are arch-specific.
|
||||
// Pass the target arch so an ARM64 package never stages an x64 binding.
|
||||
stageGetWindows({ platform, arch: archName })
|
||||
console.log(`[before-pack] re-staged get-windows for target ${platform}-${archName}`)
|
||||
}
|
||||
} catch (err) {
|
||||
// This one SHOULD fail the build — a missing/wrong native binary for the
|
||||
// target arch means a broken package shipped to users, which is worse
|
||||
// than a build that fails loudly here.
|
||||
throw new Error(`[before-pack] failed to stage native deps for this target: ${err.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import beforePack, { cleanStaleAppOutDir, preserveRollbackBackup } from '../scripts/before-pack.mjs'
|
||||
|
||||
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'linux-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
// Reproduce the corrupted partial state: license + payload present,
|
||||
// electron binary missing — exactly what trips the ENOENT rename.
|
||||
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
|
||||
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
|
||||
fs.mkdirSync(path.join(appOutDir, 'resources'), { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'resources', 'app.asar'), 'x', 'utf8')
|
||||
|
||||
const removed = cleanStaleAppOutDir(appOutDir)
|
||||
|
||||
assert.equal(removed, true)
|
||||
assert.equal(fs.existsSync(appOutDir), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cleanStaleAppOutDir is a no-op when the directory is absent', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const missing = path.join(tempRoot, 'does-not-exist')
|
||||
assert.equal(cleanStaleAppOutDir(missing), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cleanStaleAppOutDir ignores empty or invalid input', () => {
|
||||
assert.equal(cleanStaleAppOutDir(''), false)
|
||||
assert.equal(cleanStaleAppOutDir(undefined), false)
|
||||
assert.equal(cleanStaleAppOutDir(null), false)
|
||||
assert.equal(cleanStaleAppOutDir(42), false)
|
||||
})
|
||||
|
||||
test('beforePack default export resolves even when cleanup throws', async () => {
|
||||
// A directory path that rmSync can't remove is simulated by passing a
|
||||
// context whose appOutDir is a file the hook will try (and be allowed) to
|
||||
// remove; the contract under test is that the hook never rejects.
|
||||
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
|
||||
})
|
||||
|
||||
// ─── Windows rollback preservation (#69179) ────────────────────────────────
|
||||
|
||||
test('preserveRollbackBackup moves a working build to .bak', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-old-build', 'utf8')
|
||||
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
|
||||
|
||||
const preserved = preserveRollbackBackup(appOutDir, 'Hermes.exe')
|
||||
|
||||
assert.equal(preserved, true)
|
||||
// Original slot vacated so electron-builder stages into a clean tree...
|
||||
assert.equal(fs.existsSync(appOutDir), false)
|
||||
// ...and the previous working build is intact under .bak for rollback.
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
|
||||
'MZ-old-build'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('preserveRollbackBackup replaces a stale .bak from an older update', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'current', 'utf8')
|
||||
fs.mkdirSync(`${appOutDir}.bak`, { recursive: true })
|
||||
fs.writeFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'two-updates-ago', 'utf8')
|
||||
|
||||
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), true)
|
||||
assert.equal(fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), 'current')
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('preserveRollbackBackup refuses a partial tree missing the product exe', () => {
|
||||
// The corrupted partial state (interrupted prior pack) must NOT become
|
||||
// rollback material — it is exactly what cleanStaleAppOutDir exists to wipe.
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
|
||||
|
||||
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), false)
|
||||
// Tree untouched; the caller's wipe path handles it.
|
||||
assert.equal(fs.existsSync(appOutDir), true)
|
||||
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('preserveRollbackBackup ignores missing or invalid input', () => {
|
||||
assert.equal(preserveRollbackBackup(''), false)
|
||||
assert.equal(preserveRollbackBackup(undefined), false)
|
||||
assert.equal(preserveRollbackBackup(null), false)
|
||||
assert.equal(preserveRollbackBackup(path.join(os.tmpdir(), 'does-not-exist-xyz')), false)
|
||||
})
|
||||
|
||||
test('beforePack on win32 preserves the previous build instead of wiping it', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'win-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-working', 'utf8')
|
||||
|
||||
// No packager info in the context → default 'Hermes.exe' product name.
|
||||
// node-pty staging is skipped because arch is not a number here.
|
||||
await beforePack({ appOutDir, electronPlatformName: 'win32' })
|
||||
|
||||
assert.equal(fs.existsSync(appOutDir), false)
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
|
||||
'MZ-working'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('beforePack on linux keeps the plain wipe (no .bak)', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'linux-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'x', 'utf8')
|
||||
|
||||
await beforePack({ appOutDir, electronPlatformName: 'linux' })
|
||||
|
||||
assert.equal(fs.existsSync(appOutDir), false)
|
||||
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
// bundle-electron-main.mjs — bundles electron/main.ts and electron/preload.ts
|
||||
// into self-contained js files in dist/ so the packaged app doesn't need
|
||||
// node_modules/ or tsx at runtime.
|
||||
//
|
||||
// Output:
|
||||
// dist/electron-main.mjs (MJS bundle — entry point for packaged app)
|
||||
// dist/electron-preload.js (CJS bundle — loaded via BrowserWindow preload)
|
||||
//
|
||||
// `electron` and `node-pty` are external (provided by the runtime / staged
|
||||
// separately via stage-native-deps).
|
||||
import { build } from 'esbuild'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '..')
|
||||
const distDir = resolve(root, 'dist')
|
||||
mkdirSync(distDir, { recursive: true })
|
||||
|
||||
const mainEntry = resolve(root, 'electron/main.ts')
|
||||
const mainOut = resolve(distDir, 'electron-main.mjs')
|
||||
const preloadEntry = resolve(root, 'electron/preload.ts')
|
||||
const preloadOut = resolve(distDir, 'electron-preload.js')
|
||||
|
||||
const external = ['electron', 'node-pty', 'get-windows', 'fs']
|
||||
// Production bundles bake packaged=true so unpackaged `electron .` still
|
||||
// behaves like a packaged build. Dev bundles (`--dev`) leave the env alone
|
||||
// so HERMES_DESKTOP_DEV_SERVER / source-tree resolution keep working.
|
||||
const isDev = process.argv.includes('--dev')
|
||||
const define = isDev
|
||||
? {}
|
||||
: { 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) }
|
||||
|
||||
// Bundle main.ts → dist/electron-main.mjs
|
||||
await build({
|
||||
entryPoints: [mainEntry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node20',
|
||||
outfile: mainOut,
|
||||
external,
|
||||
banner: {
|
||||
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
|
||||
},
|
||||
define,
|
||||
logLevel: 'info',
|
||||
})
|
||||
console.log(`bundled ${mainOut}${isDev ? ' (dev)' : ''}`)
|
||||
|
||||
// Bundle preload.ts → dist/electron-preload.js
|
||||
await build({
|
||||
entryPoints: [preloadEntry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
outfile: preloadOut,
|
||||
external,
|
||||
define,
|
||||
logLevel: 'info',
|
||||
})
|
||||
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
|
||||
@@ -0,0 +1,51 @@
|
||||
// Click on a session by partial title match.
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (method, params = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method, params }))
|
||||
})
|
||||
|
||||
const title = process.argv[2] || 'Phaser particle'
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: `
|
||||
(() => {
|
||||
const titleMatch = ${JSON.stringify(title)}
|
||||
const all = document.querySelectorAll('button, a, div[role="button"]')
|
||||
const found = [...all].find(el => (el.textContent || '').includes(titleMatch))
|
||||
if (!found) return JSON.stringify({ found: false, tried: titleMatch })
|
||||
found.scrollIntoView()
|
||||
found.click()
|
||||
return JSON.stringify({ found: true, tag: found.tagName, text: (found.textContent || '').slice(0, 80) })
|
||||
})()
|
||||
`,
|
||||
returnByValue: true
|
||||
})
|
||||
console.log('click raw:', JSON.stringify(r, null, 2))
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
const status = await send('Runtime.evaluate', {
|
||||
expression: `JSON.stringify({
|
||||
url: location.href,
|
||||
hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'),
|
||||
threadMessages: document.querySelectorAll('[data-slot="aui_message"]').length,
|
||||
bodyTextSnippet: document.body.innerText.slice(0, 500),
|
||||
title: document.title
|
||||
})`,
|
||||
returnByValue: true
|
||||
})
|
||||
console.log('after click:', status.result.value)
|
||||
ws.close()
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Launch the desktop app with a mock inference provider — no real API
|
||||
* keys needed. Starts a local OpenAI-compatible server that returns a
|
||||
* canned reply, writes an isolated config.yaml + .env, and launches the
|
||||
* built Electron app against them.
|
||||
*
|
||||
* This reuses the same mock-server and config format as the E2E fixtures
|
||||
* (apps/desktop/e2e/mock-server.ts + fixtures.ts), so local dev and CI
|
||||
* test the same chain.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/dev-mock.mjs
|
||||
* npm run dev:mock
|
||||
*
|
||||
* The mock server listens on an ephemeral port and replies to every
|
||||
* chat completion with:
|
||||
* "Hello from the mock inference server! The full boot chain is working."
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
|
||||
// ── Canned reply ───────────────────────────────────────────────────────
|
||||
|
||||
const CANNED_REPLY =
|
||||
'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
// ── Mock server (mirrors e2e/mock-server.ts) ───────────────────────────
|
||||
|
||||
function startMockServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
object: 'list',
|
||||
data: [{ id: 'mock-model', object: 'model', created: 0, owned_by: 'mock' }],
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
|
||||
let body = ''
|
||||
req.on('data', (chunk) => { body += chunk.toString() })
|
||||
req.on('end', () => {
|
||||
let parsed = {}
|
||||
try { parsed = JSON.parse(body) } catch { /* non-streaming */ }
|
||||
|
||||
const stream = parsed.stream === true
|
||||
const model = parsed.model || 'mock-model'
|
||||
|
||||
if (stream) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
const words = CANNED_REPLY.split(' ')
|
||||
let i = 0
|
||||
const sendChunk = () => {
|
||||
if (i >= words.length) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion', object: 'chat.completion.chunk',
|
||||
created: 0, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
})}\n\n`,
|
||||
)
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion', object: 'chat.completion.chunk',
|
||||
created: 0, model,
|
||||
choices: [{ index: 0, delta: { content: word }, finish_reason: null }],
|
||||
})}\n\n`,
|
||||
)
|
||||
i++
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
sendChunk()
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion', object: 'chat.completion',
|
||||
created: 0, model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: CANNED_REPLY },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
req.on('error', () => { res.writeHead(400); res.end('Bad request') })
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Not found' }))
|
||||
})
|
||||
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
reject(new Error('Failed to get server address'))
|
||||
return
|
||||
}
|
||||
resolve({ port: addr.port, url: `http://127.0.0.1:${addr.port}`, close: () => server.close() })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Config + env writing (mirrors e2e/fixtures.ts) ─────────────────────
|
||||
|
||||
function createSandbox() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-dev-mock-${Date.now()}`))
|
||||
const hermesHome = path.join(root, 'hermes-home')
|
||||
const userDataDir = path.join(root, 'electron-user-data')
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
return { root, hermesHome, userDataDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
function writeMockConfig(hermesHome, mockUrl) {
|
||||
fs.writeFileSync(
|
||||
path.join(hermesHome, 'config.yaml'),
|
||||
`# Auto-generated by dev-mock.mjs
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
providers:
|
||||
mock:
|
||||
api: ${mockUrl}/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`,
|
||||
'utf8',
|
||||
)
|
||||
fs.writeFileSync(path.join(hermesHome, '.env'), 'MOCK_API_KEY=e2e-mock-key\n', 'utf8')
|
||||
}
|
||||
|
||||
// ── Electron launch ────────────────────────────────────────────────────
|
||||
|
||||
function findElectron() {
|
||||
const local = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
|
||||
if (fs.existsSync(local)) return local
|
||||
const r = spawnSync('which', ['electron'], { encoding: 'utf8' })
|
||||
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim()
|
||||
throw new Error('Electron binary not found. Run "npm install" from the repo root.')
|
||||
}
|
||||
|
||||
function assertDistBuilt() {
|
||||
const electronMain = path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs')
|
||||
const indexHtml = path.join(DESKTOP_ROOT, 'dist', 'index.html')
|
||||
if (!fs.existsSync(electronMain) || !fs.existsSync(indexHtml)) {
|
||||
throw new Error(
|
||||
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${electronMain}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
assertDistBuilt()
|
||||
|
||||
console.log('Starting mock inference server...')
|
||||
const mock = await startMockServer()
|
||||
console.log(` Mock server: ${mock.url}`)
|
||||
|
||||
const sandbox = createSandbox()
|
||||
writeMockConfig(sandbox.hermesHome, mock.url)
|
||||
console.log(` HERMES_HOME: ${sandbox.hermesHome}`)
|
||||
|
||||
const electronBin = findElectron()
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HERMES_HOME: sandbox.hermesHome,
|
||||
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
||||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
|
||||
HERMES_DESKTOP_APP_NAME: `HermesDevMock-${Date.now()}`,
|
||||
}
|
||||
|
||||
console.log('Launching Electron...')
|
||||
const child = spawn(electronBin, [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], {
|
||||
env,
|
||||
cwd: DESKTOP_ROOT,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('exit', (code) => {
|
||||
mock.close()
|
||||
sandbox.cleanup()
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
// Launch the desktop renderer with HMR disabled so the React Fast Refresh
|
||||
// preamble path is skipped. This sidesteps a current Vite 8 / plugin-react 6
|
||||
// bug where the preamble script is not injected into index.html → renderer
|
||||
// throws "$RefreshReg$ is not defined" on every TSX module → React tree
|
||||
// never mounts.
|
||||
//
|
||||
// We're not trying to use HMR while profiling typing lag anyway. Hermes desktop
|
||||
// boots, you type, profiler measures. HMR off is fine.
|
||||
//
|
||||
// Usage: node apps/desktop/scripts/dev-no-hmr.mjs
|
||||
// (then in another shell, run electron --remote-debugging-port=9222 .)
|
||||
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const server = await createServer({
|
||||
configFile: new URL('../vite.config.ts', import.meta.url).pathname,
|
||||
root: new URL('../', import.meta.url).pathname,
|
||||
server: { hmr: false, host: '127.0.0.1', port: 5174, strictPort: true }
|
||||
})
|
||||
await server.listen()
|
||||
server.printUrls()
|
||||
@@ -0,0 +1,25 @@
|
||||
// Is the tree-split preview path actually active in the running renderer?
|
||||
// Checks the served source (what vite compiled) rather than guessing.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const out = await cdp.eval(`(async () => {
|
||||
const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx')
|
||||
const src = await res.text()
|
||||
return JSON.stringify({
|
||||
previewShift: src.includes('previewShift'),
|
||||
adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'),
|
||||
structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'),
|
||||
sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'),
|
||||
rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider')
|
||||
})
|
||||
})()`)
|
||||
|
||||
console.log(out)
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Who re-renders the transcript during a sash drag?
|
||||
//
|
||||
// Standalone probe, not a benchmark: seeds tiles, then drags the sash while
|
||||
// recording (a) render attribution and (b) every nanostores atom that notifies
|
||||
// during the gesture. The idle-cost scenario proved the transcript re-renders
|
||||
// ~18x above baseline during a drag but that the sash HANDLER is not the cause
|
||||
// (identical counts at 0px and 60px displacement) — so this names the store
|
||||
// that actually fires.
|
||||
//
|
||||
// node scripts/perf/diag-drag-churn.mjs [--port 9222]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const TILES = 5
|
||||
const TURNS = 20
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nSome prose with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__D__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'diag-tile-' + n
|
||||
const rid = 'diag-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__D__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Drag, recording renders AND atom notifications together.
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
const ac = window.__ATOM_CHURN__
|
||||
rc.start(); ac.start()
|
||||
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) { rc.stop(); ac.stop(); return JSON.stringify({ error: 'no sash' }) }
|
||||
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x += 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
|
||||
rc.stop(); ac.stop()
|
||||
const all = rc.report(400)
|
||||
const named = n => all.find(r => r.name === n) || null
|
||||
return JSON.stringify({
|
||||
moved: Math.round(x - x0),
|
||||
commits: rc.commits(),
|
||||
renders: rc.report(14),
|
||||
// The suspects: who at the TOP of the transcript tree re-rendered?
|
||||
chain: ['ChatView', 'ChatRuntimeBoundary', 'AuiProvider', 'Thread', 'SessionTile', 'TileChat',
|
||||
'LayoutTreeRoot', 'TreeNode', 'TreeSplit', 'TreeGroup', 'SessionView', 'PaneShell']
|
||||
.map(n => ({ name: n, hit: named(n) })).filter(x => x.hit),
|
||||
atoms: ac.report(30)
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__D__) {
|
||||
for (const { sid, rid } of window.__D__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__D__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222)
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`diag-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const data = JSON.parse(await cdp.eval(DRAG))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
console.log(`moved ${data.moved}px, ${data.commits} commits\n`)
|
||||
console.log('RENDERS during drag:')
|
||||
|
||||
for (const r of data.renders) {
|
||||
console.log(
|
||||
` ${r.name.padEnd(28)} r=${String(r.renders).padStart(6)} wasted=${String(r.wasted).padStart(6)} ` +
|
||||
`props=${String(r.propsChanged).padStart(5)} state=${String(r.stateChanged).padStart(5)} ` +
|
||||
`ctx=${String(r.contextChanged ?? 0).padStart(4)} ms=${r.totalMs}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nTRANSCRIPT CHAIN (who above the messages re-rendered):')
|
||||
|
||||
for (const { name, hit } of data.chain) {
|
||||
console.log(
|
||||
` ${name.padEnd(24)} r=${String(hit.renders).padStart(6)} wasted=${String(hit.wasted).padStart(6)} ` +
|
||||
`props=${String(hit.propsChanged).padStart(5)} state=${String(hit.stateChanged).padStart(5)} ms=${hit.totalMs}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\nATOMS that notified during drag:')
|
||||
|
||||
for (const a of data.atoms) {
|
||||
console.log(
|
||||
` ${a.name.padEnd(26)} notifies=${String(a.notifies).padStart(5)} wasted=${String(a.wasted).padStart(5)} ` +
|
||||
`fanout=${String(a.fanout).padStart(6)} peakListeners=${a.peakListeners}`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// What is the drag actually spending time on?
|
||||
//
|
||||
// The render counters proved React is no longer the cost (commits 83 -> 12
|
||||
// after the $layoutTree fix) yet drag fps stayed ~3 while p95 halved. That
|
||||
// pattern says a fixed per-frame floor outside React. This takes a real CDP
|
||||
// trace of one sash drag and prints the category split — Recalculate Style,
|
||||
// Layout, Paint, Scripting — so the next fix targets the actual cost instead
|
||||
// of the next plausible-looking thing.
|
||||
//
|
||||
// node scripts/diag-drag-trace.mjs [--port 9222] [--tiles 5]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const TILES = Number(arg('tiles', 5))
|
||||
const TURNS = Number(arg('turns', 20))
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__T__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'trace-tile-' + n
|
||||
const rid = 'trace-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__T__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Drive the sash WITHOUT awaiting rAF: a slow app would stretch a rAF-paced
|
||||
// loop and make the window itself a function of the slowness. Fixed wall-clock
|
||||
// pacing keeps the trace window comparable run to run.
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) return 'none'
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 40; i++) {
|
||||
x += (i < 20 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 16))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
return 'dragged'
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__T__) {
|
||||
for (const { sid, rid } of window.__T__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__T__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`trace-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
// Collect trace events for the drag window only. `cdp.on` is the client's
|
||||
// only event API (no `once`), so completion is signalled through a flag.
|
||||
const events = []
|
||||
let complete = false
|
||||
cdp.on('Tracing.dataCollected', params => events.push(...(params.value ?? [])))
|
||||
cdp.on('Tracing.tracingComplete', () => {
|
||||
complete = true
|
||||
})
|
||||
|
||||
await cdp.send('Tracing.start', {
|
||||
transferMode: 'ReportEvents',
|
||||
traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing'] }
|
||||
})
|
||||
|
||||
const dragged = await cdp.eval(DRAG)
|
||||
|
||||
await cdp.send('Tracing.end')
|
||||
|
||||
for (let waited = 0; !complete && waited < 10000; waited += 200) {
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
// Sum self-time per timeline category. Nested events would double-count, so
|
||||
// attribute each event's duration minus the duration of its direct children.
|
||||
const INTERESTING = new Set([
|
||||
'UpdateLayoutTree', // Recalculate Style
|
||||
'Layout',
|
||||
'Paint',
|
||||
'PaintImage',
|
||||
'Layerize',
|
||||
'UpdateLayer',
|
||||
'CompositeLayers',
|
||||
'FunctionCall',
|
||||
'EvaluateScript',
|
||||
'TimerFire',
|
||||
'EventDispatch',
|
||||
'HitTest',
|
||||
'ParseHTML',
|
||||
'CommitLoad'
|
||||
])
|
||||
|
||||
const totals = new Map()
|
||||
let traced = 0
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
traced += 1
|
||||
const name = e.name
|
||||
|
||||
if (!INTERESTING.has(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
totals.set(name, (totals.get(name) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
|
||||
console.log(`drag=${dragged} trace events=${events.length} (complete=${traced})\n`)
|
||||
console.log('TIMELINE COST (ms, total duration by event):')
|
||||
|
||||
const rows = [...totals.entries()].sort((a, b) => b[1] - a[1])
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log(' (no timeline events — category filter or tracing domain unavailable)')
|
||||
}
|
||||
|
||||
for (const [name, ms] of rows) {
|
||||
console.log(` ${name.padEnd(20)} ${ms.toFixed(1)}ms`)
|
||||
}
|
||||
|
||||
const style = totals.get('UpdateLayoutTree') ?? 0
|
||||
const layout = totals.get('Layout') ?? 0
|
||||
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
|
||||
|
||||
console.log(`\nVERDICT: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms`)
|
||||
|
||||
// Script dominates -> name the functions. Timeline FunctionCall events carry
|
||||
// the callsite in args.data, so the top offenders can be attributed without
|
||||
// a separate CPU profile.
|
||||
const byFn = new Map()
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || e.name !== 'FunctionCall' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
const d = e.args?.data ?? {}
|
||||
const key = `${d.functionName || '(anonymous)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
|
||||
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
|
||||
console.log('\nTOP SCRIPT CALLSITES (ms):')
|
||||
|
||||
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15)) {
|
||||
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Wrap the thread scroller's properties and observe pin/scroll/RO events
|
||||
// in real time during a submit, then print the timeline.
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (m, p = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method: m, params: p }))
|
||||
})
|
||||
const evalP = async expr => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
|
||||
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
|
||||
return r.result.result.value
|
||||
}
|
||||
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (v) v.scrollTop = v.scrollHeight
|
||||
})()`)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
await evalP(`(() => {
|
||||
const el = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
el.focus()
|
||||
const r = document.createRange(); r.selectNodeContents(el); r.collapse(false)
|
||||
window.getSelection().removeAllRanges(); window.getSelection().addRange(r)
|
||||
})()`)
|
||||
|
||||
const text = 'short follow-up'
|
||||
for (const c of text) {
|
||||
await send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// Hook into the viewport scrollTop setter + scroll + RO so we see every event
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const events = []
|
||||
window.__threadEvents = events
|
||||
const t0 = performance.now()
|
||||
const push = (kind, detail) => events.push({ t: performance.now() - t0, kind, ...detail })
|
||||
|
||||
// intercept scrollTop writes
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
||||
Object.defineProperty(v, 'scrollTop', {
|
||||
get() { return desc.get.call(this) },
|
||||
set(val) {
|
||||
push('scrollTop=', { val, fromScrollHeight: this.scrollHeight, stackTop: (new Error()).stack.split('\\n').slice(2, 5).map(s => s.trim()).join(' | ') })
|
||||
desc.set.call(this, val)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
// scroll event
|
||||
v.addEventListener('scroll', () => {
|
||||
push('scroll', { scrollTop: v.scrollTop, scrollHeight: v.scrollHeight })
|
||||
}, { passive: true, capture: true })
|
||||
|
||||
// RO on the viewport itself
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
push('RO', { target: e.target.getAttribute('data-slot') || e.target.tagName, h: e.contentRect.height })
|
||||
}
|
||||
})
|
||||
ro.observe(v)
|
||||
if (v.firstElementChild) ro.observe(v.firstElementChild)
|
||||
|
||||
// mutationobserver on the viewport
|
||||
const mo = new MutationObserver((muts) => {
|
||||
push('mut', { count: muts.length, added: muts.reduce((s, m) => s + m.addedNodes.length, 0), removed: muts.reduce((s, m) => s + m.removedNodes.length, 0) })
|
||||
})
|
||||
mo.observe(v, { childList: true, subtree: true, characterData: true })
|
||||
|
||||
window.__teardown = () => { ro.disconnect(); mo.disconnect() }
|
||||
return true
|
||||
})()`)
|
||||
|
||||
// fire Enter
|
||||
await send('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r'
|
||||
})
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' })
|
||||
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
|
||||
const events = JSON.parse(await evalP(`JSON.stringify(window.__threadEvents || [])`))
|
||||
console.log(`\n${events.length} events:`)
|
||||
for (const e of events) {
|
||||
const t = String(e.t.toFixed(0)).padStart(5)
|
||||
const { kind, t: _t, ...rest } = e
|
||||
console.log(` ${t}ms ${kind.padEnd(12)} ${JSON.stringify(rest)}`)
|
||||
}
|
||||
|
||||
await evalP(`window.__teardown?.()`)
|
||||
// Cancel running agent
|
||||
await evalP(`(() => {
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stopped' }
|
||||
}
|
||||
})()`)
|
||||
|
||||
ws.close()
|
||||
@@ -0,0 +1,47 @@
|
||||
// Typing latency, isolated: keystroke -> next paint, with and without an
|
||||
// active stream. Distinguishes "input is slow" from "the frame budget is
|
||||
// consumed by streaming flushes" — the fix differs completely.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
const TYPE = `
|
||||
(async () => {
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
|
||||
if (!el) return JSON.stringify({ error: 'no composer' })
|
||||
el.focus()
|
||||
const perKey = []
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const ch = 'abcdefghij'[i % 10]
|
||||
const t0 = performance.now()
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
perKey.push(performance.now() - t0)
|
||||
// Human-ish 80ms cadence so streaming flushes interleave realistically.
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
}
|
||||
el.textContent = ''
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
|
||||
const sorted = [...perKey].sort((a, b) => a - b)
|
||||
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
|
||||
const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })()
|
||||
return JSON.stringify({
|
||||
keyToPaint_p50: Math.round(pct(0.5) * 10) / 10,
|
||||
keyToPaint_p95: Math.round(pct(0.95) * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
over16: perKey.filter(f => f > 16.7).length,
|
||||
over33: perKey.filter(f => f > 33).length,
|
||||
streamingParts: busy
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
console.log(await cdp.eval(TYPE))
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Quick state probe of the running hgui instance via CDP.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const state = await cdp.eval(`(() => {
|
||||
const rc = !!window.__RENDER_COUNTS__
|
||||
const pl = !!window.__PERF_LIVE__
|
||||
const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1
|
||||
const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)'
|
||||
const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length
|
||||
return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) })
|
||||
})()`)
|
||||
|
||||
console.log(state)
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
// Robust A/B: measure each surface N times, report median wasted renders.
|
||||
// Reduces timing noise from stream ticks landing on different clicks.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const RUNS = 3
|
||||
let msgId = 1
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = (data) => { const m = JSON.parse(data); if (m.id === id) { ws.off('message', h); m.error ? reject(m.error) : resolve(m.result) } }
|
||||
ws.on('message', h); ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async function ev(ws, e) { return (await send(ws, 'Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }))?.result?.value }
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
const median = arr => { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)] }
|
||||
|
||||
async function wsUrl() {
|
||||
const d = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
return d.find(t => t.type === 'page' && (t.url||'').includes('5174')).webSocketDebuggerUrl
|
||||
}
|
||||
|
||||
async function measureOnce(ws, setup, holdMs) {
|
||||
await setup(ws)
|
||||
await sleep(300)
|
||||
await ev(ws, `__RENDER_COUNTS__.start(); true`)
|
||||
await sleep(holdMs)
|
||||
const rep = JSON.parse(await ev(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
|
||||
return rep.reduce((s, c) => s + c.wasted, 0)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ws = new WebSocket(await wsUrl())
|
||||
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
|
||||
await send(ws, 'Runtime.enable')
|
||||
|
||||
const surfaces = [
|
||||
['Artifacts', ws => ev(ws, `window.location.hash='#/artifacts'; true`), 2000],
|
||||
['Messaging', ws => ev(ws, `window.location.hash='#/messaging'; true`), 2000],
|
||||
['Cron', ws => ev(ws, `window.location.hash='#/cron'; true`), 2000],
|
||||
['Profiles', ws => ev(ws, `window.location.hash='#/profiles'; true`), 2000],
|
||||
['Agents', ws => ev(ws, `window.location.hash='#/agents'; true`), 2000],
|
||||
['Starmap', ws => ev(ws, `window.location.hash='#/starmap'; true`), 2000],
|
||||
['Webhooks', ws => ev(ws, `window.location.hash='#/webhooks'; true`), 2000],
|
||||
['CommandCenter/System', async ws => { await ev(ws, `window.location.hash='#/command-center'; true`); await sleep(400); await ev(ws, `Array.from(document.querySelectorAll('button')).find(b=>b.textContent?.trim()==='System')?.click(); true`) }, 2000],
|
||||
]
|
||||
|
||||
const label = process.argv[2] || 'RUN'
|
||||
console.log(`\n=== ${label} (median of ${RUNS}, wasted renders, 2s idle) ===`)
|
||||
for (const [name, setup, hold] of surfaces) {
|
||||
const runs = []
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
runs.push(await measureOnce(ws, setup, hold))
|
||||
await ev(ws, `window.location.hash='#/'; true`); await sleep(300)
|
||||
}
|
||||
console.log(` ${name.padEnd(24)} median ${String(median(runs)).padStart(6)} (runs: ${runs.join(', ')})`)
|
||||
}
|
||||
ws.close()
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
// CDP probe: measure render churn on overlay surfaces (cmdk, settings, command-center, skills)
|
||||
// against the user's live instance on :9222. Read-only — never closes or navigates away.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const CDP_URL = 'ws://127.0.0.1:9222'
|
||||
const TARGET_GLOB = '/devtools/page/'
|
||||
|
||||
let msgId = 1
|
||||
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.id === id) {
|
||||
ws.off('message', handler)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
}
|
||||
ws.on('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async function getRendererTarget() {
|
||||
const resp = await fetch('http://127.0.0.1:9222/json/list')
|
||||
const targets = await resp.json()
|
||||
// Find the renderer target (not devtools)
|
||||
return targets.find(t => t.url?.startsWith('http://127.0.0.1:5174') || t.url?.includes('5174'))
|
||||
?? targets.find(t => t.type === 'page' && !t.url.startsWith('devtools'))
|
||||
}
|
||||
|
||||
async function evalInPage(ws, expr) {
|
||||
const result = await send(ws, 'Runtime.evaluate', {
|
||||
expression: expr,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})
|
||||
return result?.result?.value
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const target = await getRendererTarget()
|
||||
if (!target) {
|
||||
console.error('No renderer target found')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Target: ${target.url}`)
|
||||
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on('open', resolve)
|
||||
ws.on('error', reject)
|
||||
})
|
||||
|
||||
await send(ws, 'Runtime.enable')
|
||||
|
||||
// Check if __RENDER_COUNTS__ is available
|
||||
const hasCounter = await evalInPage(ws, 'typeof __RENDER_COUNTS__')
|
||||
console.log(`__RENDER_COUNTS__ available: ${hasCounter}`)
|
||||
|
||||
if (hasCounter !== 'object') {
|
||||
console.log('Render counter not loaded — checking perf-live...')
|
||||
const hasPerfLive = await evalInPage(ws, 'typeof __PERF_LIVE__')
|
||||
console.log(`__PERF_LIVE__ available: ${hasPerfLive}`)
|
||||
}
|
||||
|
||||
// 1. Baseline: open cmdk, type a few chars, measure
|
||||
console.log('\n=== CmdK Palette ===')
|
||||
await evalInPage(ws, `
|
||||
window.__renderBaseline = {}
|
||||
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
|
||||
__RENDER_COUNTS__.reset()
|
||||
}
|
||||
// Open cmdk via keyboard shortcut
|
||||
const evt = new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true })
|
||||
document.dispatchEvent(evt)
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
|
||||
// Type some characters
|
||||
for (const ch of ['s', 'e', 't', 't', 'i', 'n', 'g']) {
|
||||
await evalInPage(ws, `
|
||||
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[placeholder]')
|
||||
if (el) {
|
||||
el.focus()
|
||||
el.value = el.value + '${ch}'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const cmdkCounts = await evalInPage(ws, `
|
||||
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
|
||||
`)
|
||||
console.log('CmdK render counts after typing "setting":', JSON.stringify(cmdkCounts, null, 2))
|
||||
|
||||
// Close cmdk
|
||||
await evalInPage(ws, `new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); true`)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// 2. Command Center — measure renders while on System tab
|
||||
console.log('\n=== Command Center (System tab) ===')
|
||||
await evalInPage(ws, `
|
||||
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
|
||||
__RENDER_COUNTS__.reset()
|
||||
}
|
||||
// Navigate to command center system section
|
||||
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('System') || el.textContent?.includes('Command Center'))
|
||||
if (link) link.click()
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
const ccCounts = await evalInPage(ws, `
|
||||
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
|
||||
`)
|
||||
console.log('Command Center render counts (after 1s on System tab):', JSON.stringify(ccCounts, null, 2))
|
||||
|
||||
// 3. Settings page — measure renders on nav
|
||||
console.log('\n=== Settings Page ===')
|
||||
await evalInPage(ws, `
|
||||
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
|
||||
__RENDER_COUNTS__.reset()
|
||||
}
|
||||
// Navigate to settings
|
||||
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('Settings'))
|
||||
if (link) link.click()
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
// Click through a few nav items
|
||||
for (const label of ['Appearance', 'Gateway', 'Keys', 'About']) {
|
||||
await evalInPage(ws, `
|
||||
const el = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '${label}')
|
||||
if (el) el.click()
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
}
|
||||
|
||||
const settingsCounts = await evalInPage(ws, `
|
||||
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
|
||||
`)
|
||||
console.log('Settings render counts (after clicking 4 nav items):', JSON.stringify(settingsCounts, null, 2))
|
||||
|
||||
ws.close()
|
||||
console.log('\nDone.')
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
// Comprehensive overlay render-churn measurement on the live instance.
|
||||
// Measures: cmdk root + submenus, settings, command center, capabilities, system overlays.
|
||||
// Read-only — never closes the app, only navigates and clicks.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const WS_URL = 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
|
||||
let msgId = 1
|
||||
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.id === id) {
|
||||
ws.off('message', handler)
|
||||
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
|
||||
}
|
||||
}
|
||||
ws.on('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async function eval_(ws, expr) {
|
||||
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
return r?.result?.value
|
||||
}
|
||||
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
async function measure(ws, label, fn, settleMs = 400) {
|
||||
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
|
||||
await fn(ws)
|
||||
await sleep(settleMs)
|
||||
const report = await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`)
|
||||
const parsed = JSON.parse(report)
|
||||
const totalRenders = parsed.reduce((s, c) => s + c.renders, 0)
|
||||
const totalWasted = parsed.reduce((s, c) => s + c.wasted, 0)
|
||||
const totalMs = parsed.reduce((s, c) => s + c.totalMs, 0)
|
||||
const top5 = parsed.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 5)
|
||||
console.log(`\n${label}`)
|
||||
console.log(` total: ${totalRenders} renders, ${totalWasted} wasted, ${totalMs.toFixed(1)}ms`)
|
||||
if (top5.length > 0) {
|
||||
for (const c of top5) {
|
||||
console.log(` ${c.name}: ${c.renders} renders, ${c.wasted} wasted, ${c.totalMs.toFixed(1)}ms`)
|
||||
}
|
||||
} else {
|
||||
console.log(` (no wasted renders)`)
|
||||
}
|
||||
return { label, totalRenders, totalWasted, totalMs, top: top5 }
|
||||
}
|
||||
|
||||
async function clickByText(ws, text, tag = 'button') {
|
||||
return eval_(ws, `
|
||||
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === '${text}')
|
||||
if (el) { el.click(); true } else false
|
||||
`)
|
||||
}
|
||||
|
||||
async function clickByHref(ws, partial) {
|
||||
return eval_(ws, `
|
||||
const el = Array.from(document.querySelectorAll('a,button')).find(e => e.getAttribute('href')?.includes('${partial}'))
|
||||
if (el) { el.click(); true } else false
|
||||
`)
|
||||
}
|
||||
|
||||
async function typeInInput(ws, text) {
|
||||
for (const ch of text) {
|
||||
await eval_(ws, `
|
||||
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[type="text"]') || document.querySelector('input')
|
||||
if (el) { el.focus(); el.value = el.value + '${ch}'; el.dispatchEvent(new Event('input', {bubbles:true})) }
|
||||
true
|
||||
`)
|
||||
await sleep(50)
|
||||
}
|
||||
}
|
||||
|
||||
async function pressKey(ws, key, mods = {}) {
|
||||
await eval_(ws, `
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '${key}', ${Object.entries(mods).map(([k,v]) => `${k}:${v}`).join(', ')}, bubbles: true }))
|
||||
true
|
||||
`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ws = new WebSocket(WS_URL)
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on('open', resolve)
|
||||
ws.on('error', reject)
|
||||
})
|
||||
await send(ws, 'Runtime.enable')
|
||||
|
||||
const avail = await eval_(ws, `typeof __RENDER_COUNTS__`)
|
||||
console.log(`__RENDER_COUNTS__: ${avail}`)
|
||||
if (avail !== 'object') {
|
||||
console.error('Render counter not loaded. Reload the page.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const results = []
|
||||
|
||||
// === 1. CmdK root: open, type "setting", close ===
|
||||
results.push(await measure(ws, '1. CmdK root (type "setting")', async (ws) => {
|
||||
await pressKey(ws, 'k', { metaKey: true })
|
||||
await sleep(300)
|
||||
await typeInInput(ws, 'setting')
|
||||
}))
|
||||
await pressKey(ws, 'Escape')
|
||||
await sleep(300)
|
||||
|
||||
// === 2. CmdK submenu: open, navigate to theme picker, click a theme, back ===
|
||||
results.push(await measure(ws, '2. CmdK theme submenu', async (ws) => {
|
||||
await pressKey(ws, 'k', { metaKey: true })
|
||||
await sleep(300)
|
||||
await typeInInput(ws, 'theme')
|
||||
await sleep(200)
|
||||
// Click first theme item
|
||||
await eval_(ws, `
|
||||
const item = document.querySelector('[cmdk-item]')
|
||||
if (item) item.click()
|
||||
true
|
||||
`)
|
||||
await sleep(300)
|
||||
}))
|
||||
await pressKey(ws, 'Escape')
|
||||
await sleep(300)
|
||||
|
||||
// === 3. CmdK color-mode submenu ===
|
||||
results.push(await measure(ws, '3. CmdK color-mode submenu', async (ws) => {
|
||||
await pressKey(ws, 'k', { metaKey: true })
|
||||
await sleep(300)
|
||||
await typeInInput(ws, 'color mode')
|
||||
await sleep(200)
|
||||
await eval_(ws, `const item = document.querySelector('[cmdk-item]'); if (item) item.click(); true`)
|
||||
await sleep(300)
|
||||
}))
|
||||
await pressKey(ws, 'Escape')
|
||||
await sleep(300)
|
||||
|
||||
// === 4. Settings: open, click through nav items ===
|
||||
results.push(await measure(ws, '4. Settings (5 nav clicks)', async (ws) => {
|
||||
await clickByHref(ws, 'settings')
|
||||
await sleep(500)
|
||||
for (const label of ['Appearance', 'Gateway', 'Keys', 'Notifications', 'About']) {
|
||||
await clickByText(ws, label)
|
||||
await sleep(100)
|
||||
}
|
||||
}))
|
||||
|
||||
// === 5. Command Center: open to System tab, idle 2s ===
|
||||
results.push(await measure(ws, '5. Command Center (System tab, 2s idle)', async (ws) => {
|
||||
await clickByHref(ws, 'command-center')
|
||||
await sleep(500)
|
||||
await clickByText(ws, 'System')
|
||||
await sleep(2000)
|
||||
}, 100))
|
||||
|
||||
// === 6. Command Center: Sessions tab ===
|
||||
results.push(await measure(ws, '6. Command Center (Sessions tab, 1s)', async (ws) => {
|
||||
await clickByText(ws, 'Sessions')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// === 7. Capabilities/Skills page ===
|
||||
results.push(await measure(ws, '7. Capabilities (Skills tab, 1s)', async (ws) => {
|
||||
await clickByHref(ws, 'skills')
|
||||
await sleep(800)
|
||||
// Click Skills tab if not already
|
||||
await clickByText(ws, 'Skills')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// === 8. Capabilities Toolsets tab ===
|
||||
results.push(await measure(ws, '8. Capabilities (Toolsets tab, 1s)', async (ws) => {
|
||||
await clickByText(ws, 'Tools')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// === 9. Capabilities MCP tab ===
|
||||
results.push(await measure(ws, '9. Capabilities (MCP tab, 1s)', async (ws) => {
|
||||
await clickByText(ws, 'MCP')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// Navigate back to chat
|
||||
await eval_(ws, `window.location.hash = '#/'; true`)
|
||||
await sleep(300)
|
||||
|
||||
// Summary table
|
||||
console.log('\n\n=== SUMMARY ===')
|
||||
console.log('Surface'.padEnd(45) + 'Renders'.padStart(10) + 'Wasted'.padStart(10) + 'ms'.padStart(10))
|
||||
console.log('-'.repeat(75))
|
||||
for (const r of results) {
|
||||
console.log(r.label.padEnd(45) + String(r.totalRenders).padStart(10) + String(r.totalWasted).padStart(10) + r.totalMs.toFixed(1).padStart(10))
|
||||
}
|
||||
|
||||
ws.close()
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
// Full surface sweep: every overlay, every settings sub-page, every system overlay.
|
||||
// Read-only — navigates and clicks, never closes the app.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const WS_URL = process.env.CDP_WS || 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
|
||||
let msgId = 1
|
||||
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.id === id) {
|
||||
ws.off('message', handler)
|
||||
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
|
||||
}
|
||||
}
|
||||
ws.on('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async function eval_(ws, expr) {
|
||||
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
return r?.result?.value
|
||||
}
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
async function measure(ws, label, fn, settleMs = 300) {
|
||||
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
|
||||
await fn(ws)
|
||||
await sleep(settleMs)
|
||||
const report = JSON.parse(await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
|
||||
const totalRenders = report.reduce((s, c) => s + c.renders, 0)
|
||||
const totalWasted = report.reduce((s, c) => s + c.wasted, 0)
|
||||
const totalMs = report.reduce((s, c) => s + c.totalMs, 0)
|
||||
const top = report.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 6)
|
||||
console.log(`\n${label}`)
|
||||
console.log(` ${totalRenders} renders · ${totalWasted} wasted · ${totalMs.toFixed(1)}ms`)
|
||||
for (const c of top) console.log(` ${c.name}: ${c.renders}r / ${c.wasted}w / ${c.totalMs.toFixed(1)}ms`)
|
||||
if (top.length === 0) console.log(` (clean)`)
|
||||
return { label, totalRenders, totalWasted, totalMs }
|
||||
}
|
||||
|
||||
async function nav(ws, hash) {
|
||||
await eval_(ws, `window.location.hash = '#${hash}'; true`)
|
||||
}
|
||||
async function clickText(ws, text, tag='button') {
|
||||
return eval_(ws, `
|
||||
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === ${JSON.stringify(text)})
|
||||
if (el) { el.click(); true } else false
|
||||
`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ws = new WebSocket(WS_URL)
|
||||
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
|
||||
await send(ws, 'Runtime.enable')
|
||||
if (await eval_(ws, `typeof __RENDER_COUNTS__`) !== 'object') { console.error('counter not loaded'); process.exit(1) }
|
||||
|
||||
const results = []
|
||||
|
||||
// System overlays — open each, idle 2s (streaming underneath is the churn test)
|
||||
for (const [label, route] of [
|
||||
['Artifacts', '/artifacts'],
|
||||
['Messaging', '/messaging'],
|
||||
['Cron', '/cron'],
|
||||
['Profiles', '/profiles'],
|
||||
['Agents', '/agents'],
|
||||
['Starmap', '/starmap'],
|
||||
['Webhooks', '/webhooks'],
|
||||
]) {
|
||||
results.push(await measure(ws, `OVERLAY: ${label} (open + 2s idle)`, async (ws) => {
|
||||
await nav(ws, route)
|
||||
await sleep(2200)
|
||||
}, 100))
|
||||
await nav(ws, '/')
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
// Settings sub-pages — open settings, click each nav item, idle 1.5s on it
|
||||
await nav(ws, '/settings')
|
||||
await sleep(600)
|
||||
for (const label of ['Model','Session','Appearance','Notifications','Providers','Gateway','Keybinds','API Keys','Plugins','Archived Chats','About']) {
|
||||
results.push(await measure(ws, `SETTINGS: ${label} (1.5s idle)`, async (ws) => {
|
||||
await clickText(ws, label)
|
||||
await sleep(1500)
|
||||
}, 100))
|
||||
}
|
||||
await nav(ws, '/')
|
||||
await sleep(300)
|
||||
|
||||
// Messaging sub-tabs (platform detail) — click through if present
|
||||
await nav(ws, '/messaging')
|
||||
await sleep(800)
|
||||
results.push(await measure(ws, 'MESSAGING: idle 2s w/ platform list', async (ws) => {
|
||||
await sleep(2000)
|
||||
}, 100))
|
||||
await nav(ws, '/')
|
||||
await sleep(300)
|
||||
|
||||
console.log('\n\n=== SUMMARY (wasted renders) ===')
|
||||
console.log('Surface'.padEnd(48) + 'Renders'.padStart(9) + 'Wasted'.padStart(9) + 'ms'.padStart(9))
|
||||
console.log('-'.repeat(75))
|
||||
for (const r of results) {
|
||||
console.log(r.label.padEnd(48) + String(r.totalRenders).padStart(9) + String(r.totalWasted).padStart(9) + r.totalMs.toFixed(1).padStart(9))
|
||||
}
|
||||
ws.close()
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,149 @@
|
||||
// The real-app perf loop: drive HER hgui instance (real profile, real
|
||||
// sessions) through the three interactions that matter — session switch,
|
||||
// sidebar drag, composer typing — and report honest single-clock numbers.
|
||||
//
|
||||
// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6]
|
||||
//
|
||||
// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session
|
||||
// switching is measured as the user feels it: click -> transcript painted.
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const SWITCHES = Number(arg('switches', 6))
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session switch: click a sidebar session row, await the transcript settling.
|
||||
// Measures click -> first paint of the new transcript AND click -> settled
|
||||
// (two rAFs with no further DOM mutation in the thread viewport).
|
||||
// ---------------------------------------------------------------------------
|
||||
const SWITCH = swaps => `
|
||||
(async () => {
|
||||
const rows = [...document.querySelectorAll('[data-slot="row-button"]')]
|
||||
.filter(el => el.offsetParent && (el.textContent ?? '').trim())
|
||||
if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length })
|
||||
|
||||
const results = []
|
||||
for (let i = 0; i < ${swaps}; i++) {
|
||||
const row = rows[i % Math.min(rows.length, 4)]
|
||||
const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const t0 = performance.now()
|
||||
row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 }))
|
||||
row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }))
|
||||
row.click()
|
||||
|
||||
// First paint: next two rAFs after the click.
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||
const firstPaint = performance.now() - t0
|
||||
|
||||
// Settled: no mutations in the viewport for 2 consecutive frames, capped 3s.
|
||||
let lastMutation = performance.now()
|
||||
const target = viewport() ?? document.body
|
||||
const mo = new MutationObserver(() => { lastMutation = performance.now() })
|
||||
mo.observe(target, { childList: true, subtree: true, characterData: true })
|
||||
const deadline = performance.now() + 3000
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
if (performance.now() - lastMutation > 120) break
|
||||
}
|
||||
mo.disconnect()
|
||||
results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) })
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
}
|
||||
return JSON.stringify(results)
|
||||
})()
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag the first visible sash, single-clock frames.
|
||||
// ---------------------------------------------------------------------------
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0)
|
||||
if (!handle) return JSON.stringify({ error: 'no sash' })
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 60; i++) {
|
||||
x += (i < 30 ? 2 : -2)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const now = performance.now(); frames.push(now - last); last = now
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y }))
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
return JSON.stringify({
|
||||
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
|
||||
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
slow33: frames.filter(f => f > 33).length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type into the composer, single-clock frames (one mark per keystroke frame).
|
||||
// ---------------------------------------------------------------------------
|
||||
const TYPE = `
|
||||
(async () => {
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
|
||||
if (!el) return JSON.stringify({ error: 'no composer' })
|
||||
el.focus()
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ch = 'the quick brown fox '[i % 20]
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const now = performance.now(); frames.push(now - last); last = now
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
}
|
||||
// Clear what we typed.
|
||||
el.textContent = ''
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
|
||||
const moving = frames
|
||||
const total = moving.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...moving].sort((a, b) => a - b)
|
||||
return JSON.stringify({
|
||||
fps: Math.round((moving.length / total) * 1000 * 10) / 10,
|
||||
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
slow33: moving.filter(f => f > 33).length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
console.log('== SESSION SWITCH (click -> paint / settled ms) ==')
|
||||
console.log(await cdp.eval(SWITCH(SWITCHES)))
|
||||
|
||||
await sleep(500)
|
||||
console.log('\n== SIDEBAR DRAG ==')
|
||||
console.log(await cdp.eval(DRAG))
|
||||
|
||||
await sleep(500)
|
||||
console.log('\n== COMPOSER TYPING ==')
|
||||
console.log(await cdp.eval(TYPE))
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// How many ResizeObserver callbacks does one sash drag actually fire, and for
|
||||
// how many DISTINCT elements? The trace named use-resize-observer.ts at 977ms
|
||||
// but not whether that's a few expensive calls or a great many cheap ones —
|
||||
// and the fix differs completely between those.
|
||||
//
|
||||
// node scripts/diag-ro-storm.mjs [--port 9222] [--tiles 5]
|
||||
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const TILES = Number(arg('tiles', 5))
|
||||
const TURNS = Number(arg('turns', 20))
|
||||
|
||||
const setup = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff and its error path.' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
|
||||
])
|
||||
window.__R__ = { ids: [] }
|
||||
for (let n = 1; n <= ${TILES}; n++) {
|
||||
const sid = 'ro-tile-' + n
|
||||
const rid = 'ro-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working.' }] })
|
||||
window.__R__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
// Patch ResizeObserver to count callbacks + distinct observed targets, then
|
||||
// drag and report. Counting happens in the page so nothing crosses CDP per call.
|
||||
//
|
||||
// NOTE: the app's shared observer (hooks/use-resize-observer.ts) is created
|
||||
// lazily on first use, so this patch must be installed BEFORE any surface
|
||||
// mounts — otherwise the shared instance is a native one this wrapper never
|
||||
// sees and every counter reads zero. `constructed` is the tell: a run showing
|
||||
// a handful of constructions and zero callbacks means the patch landed late,
|
||||
// not that the app stopped observing.
|
||||
const INSTRUMENT = `
|
||||
(() => {
|
||||
if (window.__ROSTATS__) return 'already'
|
||||
const Native = window.ResizeObserver
|
||||
const stats = { constructed: 0, observed: 0, callbacks: 0, entries: 0, targets: new Set(), on: false }
|
||||
window.__ROSTATS__ = stats
|
||||
window.ResizeObserver = class extends Native {
|
||||
constructor(cb) {
|
||||
super((entries, obs) => {
|
||||
if (stats.on) {
|
||||
stats.callbacks += 1
|
||||
stats.entries += entries.length
|
||||
for (const e of entries) stats.targets.add(e.target)
|
||||
}
|
||||
return cb(entries, obs)
|
||||
})
|
||||
stats.constructed += 1
|
||||
}
|
||||
observe(...args) {
|
||||
stats.observed += 1
|
||||
return super.observe(...args)
|
||||
}
|
||||
}
|
||||
return 'patched'
|
||||
})()
|
||||
`
|
||||
|
||||
const DRAG = `
|
||||
(async () => {
|
||||
const s = window.__ROSTATS__
|
||||
s.callbacks = 0; s.entries = 0; s.targets = new Set(); s.on = true
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) { s.on = false; return JSON.stringify({ error: 'no sash' }) }
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
const t0 = performance.now()
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 40; i++) {
|
||||
x += (i < 20 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 16))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
s.on = false
|
||||
return JSON.stringify({
|
||||
ms: Math.round(performance.now() - t0),
|
||||
moves: 40,
|
||||
constructed: s.constructed,
|
||||
observed: s.observed,
|
||||
callbacks: s.callbacks,
|
||||
entries: s.entries,
|
||||
distinctTargets: s.targets.size,
|
||||
userBubbles: document.querySelectorAll('[data-slot="aui_user-message-root"]').length
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__R__) {
|
||||
for (const { sid, rid } of window.__R__.ids) {
|
||||
const s = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__R__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.eval(INSTRUMENT)
|
||||
|
||||
const ok = await cdp.eval(setup)
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`setup failed: ${ok}`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= TILES; n++) {
|
||||
await cdp.eval(reveal(`ro-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const r = JSON.parse(await cdp.eval(DRAG))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
console.log(JSON.stringify(r, null, 2))
|
||||
|
||||
if (r.moves) {
|
||||
console.log(`\nper pointermove: ${(r.entries / r.moves).toFixed(1)} RO entries`)
|
||||
console.log(`distinct elements resized: ${r.distinctTargets} (user bubbles in DOM: ${r.userBubbles})`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Reproduce + diagnose the "scroll wheel resets position while reading" bug.
|
||||
//
|
||||
// The complaint (Windows, mouse wheel): scrolling UP through a chat to re-read
|
||||
// older content randomly yanks the view to a different position, so you have to
|
||||
// fight the scrollbar. Mac users on trackpads don't see it.
|
||||
//
|
||||
// Hypothesis: the thread scroller has the browser default `overflow-anchor:
|
||||
// auto`, and the thread renders items in natural document flow (padding
|
||||
// spacers, NOT transforms). When an item above the viewport is measured by
|
||||
// @tanstack/react-virtual (its real height differs a lot from the 220px
|
||||
// estimate) — or when Shiki/images/fonts reflow it — TWO mechanisms both
|
||||
// adjust scrollTop for the same delta: TanStack's measurement compensation AND
|
||||
// the browser's native scroll anchoring. The double-correction lurches the
|
||||
// view. A mouse wheel's coarse, discrete notches mount/measure several
|
||||
// under-estimated turns per tick, so the over-correction is large and visible;
|
||||
// a trackpad's ~1-3px/frame keeps it sub-perceptual.
|
||||
//
|
||||
// This script drives synthetic mouse-wheel-UP scrolling on a long thread and
|
||||
// measures how much a tracked on-screen turn jumps, first with
|
||||
// `overflow-anchor: auto` (reproduce) then `overflow-anchor: none` (the fix).
|
||||
// If the fix run shows dramatically fewer/smaller jumps, the hypothesis holds.
|
||||
//
|
||||
// Prereq: a running desktop app with remote debugging on 9222, on a thread
|
||||
// with enough history to scroll (the longer / more code+tool blocks, the
|
||||
// better the repro). Then: node apps/desktop/scripts/diag-scroll-reset.mjs
|
||||
|
||||
const NOTCHES = 14 // wheel-up ticks per sweep
|
||||
const NOTCH_PX = 120 // Windows wheel notch ≈ 120px
|
||||
const NOTCH_GAP_MS = 130 // let each smooth-scroll animation settle
|
||||
const REVERSE_JUMP_PX = 6 // tracked turn moving UP while scrolling up = wrong way
|
||||
const LURCH_PX = 60 // single-frame on-screen jump that reads as a "reset"
|
||||
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
if (!tgt) {
|
||||
console.error('No page target on :9222. Is the desktop app running with --remote-debugging-port=9222?')
|
||||
process.exit(1)
|
||||
}
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (m, p = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method: m, params: p }))
|
||||
})
|
||||
const evalP = async expr => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
|
||||
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
|
||||
return r.result.result.value
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
// Install per-sweep instrumentation. `mode` is the overflow-anchor value to
|
||||
// force inline so we A/B the exact same thread regardless of any CSS fix.
|
||||
// Starts from ~45% down the thread so there's room to scroll up into
|
||||
// not-yet-measured turns, tags the turn nearest viewport-center as the anchor,
|
||||
// then records (per rAF) scrollTop + that turn's on-screen top, plus every
|
||||
// scrollTop *setter* write (TanStack compensation) and ResizeObserver hit.
|
||||
async function arm(mode) {
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!v) throw new Error('thread viewport not found')
|
||||
|
||||
// Force the overflow-anchor behavior under test (inline beats CSS).
|
||||
v.style.overflowAnchor = ${JSON.stringify(mode)}
|
||||
|
||||
// Park ~45% down so a wheel-up sweep climbs into estimated-but-unmeasured
|
||||
// turns above the fold (where the measurement correction fires).
|
||||
v.scrollTop = Math.round(v.scrollHeight * 0.45)
|
||||
|
||||
// Tag the turn closest to viewport center; we track its on-screen top.
|
||||
const vr = v.getBoundingClientRect()
|
||||
const center = vr.top + v.clientHeight / 2
|
||||
let best = null, bestD = Infinity
|
||||
for (const el of v.querySelectorAll('[data-index]')) {
|
||||
const r = el.getBoundingClientRect()
|
||||
const d = Math.abs((r.top + r.height / 2) - center)
|
||||
if (d < bestD) { bestD = d; best = el }
|
||||
}
|
||||
document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))
|
||||
if (best) best.setAttribute('data-se-anchor', '1')
|
||||
const anchorIndex = best ? best.getAttribute('data-index') : null
|
||||
|
||||
const samples = []
|
||||
const writes = []
|
||||
const ros = []
|
||||
const t0 = performance.now()
|
||||
|
||||
// Intercept scrollTop writes → these are JS (TanStack) corrections.
|
||||
// Native browser scroll anchoring does NOT go through this setter, so a
|
||||
// scrollTop change with no write in the same frame is a native adjust.
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
||||
Object.defineProperty(v, 'scrollTop', {
|
||||
configurable: true,
|
||||
get() { return desc.get.call(this) },
|
||||
set(val) {
|
||||
writes.push({ t: performance.now() - t0, val, sh: this.scrollHeight })
|
||||
desc.set.call(this, val)
|
||||
}
|
||||
})
|
||||
window.__restoreScrollTop = () => Object.defineProperty(v, 'scrollTop', desc)
|
||||
|
||||
const ro = new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
ros.push({ t: performance.now() - t0, slot: e.target.getAttribute?.('data-slot') || e.target.tagName, h: Math.round(e.contentRect.height) })
|
||||
}
|
||||
})
|
||||
ro.observe(v)
|
||||
if (v.firstElementChild) ro.observe(v.firstElementChild)
|
||||
|
||||
let running = true
|
||||
const tick = () => {
|
||||
if (!running) return
|
||||
const a = v.querySelector('[data-se-anchor]')
|
||||
const ar = a ? a.getBoundingClientRect() : null
|
||||
samples.push({
|
||||
t: performance.now() - t0,
|
||||
st: Math.round(v.scrollTop * 100) / 100,
|
||||
sh: v.scrollHeight,
|
||||
ch: v.clientHeight,
|
||||
atop: ar ? Math.round(ar.top * 100) / 100 : null,
|
||||
aconn: !!a
|
||||
})
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__se = { samples, writes, ros, anchorIndex, dpr: window.devicePixelRatio, stop() { running = false; ro.disconnect(); window.__restoreScrollTop?.() } }
|
||||
return true
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function wheelUpSweep() {
|
||||
const { x, y } = await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const r = v.getBoundingClientRect()
|
||||
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }
|
||||
})()`)
|
||||
|
||||
for (let i = 0; i < NOTCHES; i++) {
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY: -NOTCH_PX })
|
||||
await sleep(NOTCH_GAP_MS)
|
||||
}
|
||||
await sleep(400)
|
||||
}
|
||||
|
||||
async function collect() {
|
||||
const data = JSON.parse(await evalP(`(() => { window.__se.stop(); return JSON.stringify(window.__se) })()`))
|
||||
return data
|
||||
}
|
||||
|
||||
function analyze(label, data) {
|
||||
const { samples, writes, ros, anchorIndex, dpr } = data
|
||||
let reverseJumps = 0
|
||||
let reverseSum = 0
|
||||
let lurches = 0
|
||||
let maxJump = 0
|
||||
let nativeMoves = 0
|
||||
let prev = null
|
||||
for (const s of samples) {
|
||||
if (prev && prev.aconn && s.aconn && prev.atop != null && s.atop != null) {
|
||||
const dTop = s.atop - prev.atop // wheel-up should move content DOWN → dTop >= 0
|
||||
const dSt = s.st - prev.st
|
||||
// Native (browser-anchoring) move: scrollTop changed with no setter write in this frame window.
|
||||
const wroteThisFrame = writes.some(w => w.t > prev.t && w.t <= s.t)
|
||||
if (Math.abs(dSt) > 0.5 && !wroteThisFrame) nativeMoves++
|
||||
if (dTop < -REVERSE_JUMP_PX) {
|
||||
reverseJumps++
|
||||
reverseSum += -dTop
|
||||
}
|
||||
if (Math.abs(dTop) > LURCH_PX) lurches++
|
||||
if (Math.abs(dTop) > maxJump) maxJump = Math.abs(dTop)
|
||||
}
|
||||
prev = s
|
||||
}
|
||||
console.log(`\n── ${label} ──`)
|
||||
console.log(` devicePixelRatio: ${dpr}${Number.isInteger(dpr) ? '' : ' (fractional — Windows scaling, worsens rounding jitter)'}`)
|
||||
console.log(` tracked turn index: ${anchorIndex}`)
|
||||
console.log(` rAF frames: ${samples.length}`)
|
||||
console.log(` scrollTop writes: ${writes.length} (TanStack measurement corrections)`)
|
||||
console.log(` ResizeObserver hits: ${ros.length}`)
|
||||
console.log(` native scroll moves: ${nativeMoves} (scrollTop moved with NO JS write = browser anchoring)`)
|
||||
console.log(` reverse jumps: ${reverseJumps} (tracked turn yanked UP while scrolling up; total ${reverseSum.toFixed(0)}px)`)
|
||||
console.log(` big lurches (>${LURCH_PX}px): ${lurches}`)
|
||||
console.log(` max single-frame jump: ${maxJump.toFixed(0)}px`)
|
||||
return { reverseJumps, reverseSum, lurches, maxJump, nativeMoves }
|
||||
}
|
||||
|
||||
console.log(`Wheel-up repro: ${NOTCHES} notches × ${NOTCH_PX}px, anchored mid-thread.\n`)
|
||||
|
||||
await arm('auto')
|
||||
await sleep(150)
|
||||
await wheelUpSweep()
|
||||
const a = analyze('overflow-anchor: auto (current / repro)', await collect())
|
||||
|
||||
await sleep(300)
|
||||
|
||||
await arm('none')
|
||||
await sleep(150)
|
||||
await wheelUpSweep()
|
||||
const b = analyze('overflow-anchor: none (proposed fix)', await collect())
|
||||
|
||||
// Clean up our tag.
|
||||
await evalP(`document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))`)
|
||||
|
||||
console.log('\n══ verdict ══')
|
||||
const drop = (x, y) => (x === 0 ? (y === 0 ? '0' : 'n/a') : `${Math.round((1 - y / x) * 100)}% fewer`)
|
||||
console.log(` reverse jumps: auto=${a.reverseJumps} none=${b.reverseJumps} (${drop(a.reverseJumps, b.reverseJumps)})`)
|
||||
console.log(` big lurches: auto=${a.lurches} none=${b.lurches} (${drop(a.lurches, b.lurches)})`)
|
||||
console.log(` max jump: auto=${a.maxJump.toFixed(0)}px none=${b.maxJump.toFixed(0)}px`)
|
||||
console.log(` native moves: auto=${a.nativeMoves} none=${b.nativeMoves} (browser anchoring should ~vanish at none)`)
|
||||
if (a.reverseJumps + a.lurches > 0 && b.reverseJumps + b.lurches < a.reverseJumps + a.lurches) {
|
||||
console.log('\n → Jumps drop sharply with overflow-anchor:none → root cause confirmed.')
|
||||
} else if (a.reverseJumps + a.lurches === 0) {
|
||||
console.log('\n → No jumps captured this run. Use a longer thread (many code/tool blocks),')
|
||||
console.log(' raise NOTCHES, and ensure you start scrolled up from the bottom.')
|
||||
}
|
||||
|
||||
ws.close()
|
||||
@@ -0,0 +1,27 @@
|
||||
// Dump the sidebar's actual DOM shape so selectors stop being guesses.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const out = await cdp.eval(`(() => {
|
||||
const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside')
|
||||
if (!sidebar) return '(no sidebar el)'
|
||||
// Find clickable rows: anchors or buttons with text, depth-limited sample.
|
||||
const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60)
|
||||
const rows = clickables.map(el => ({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
slot: el.getAttribute('data-slot') ?? '',
|
||||
cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40),
|
||||
text: (el.textContent ?? '').trim().slice(0, 30),
|
||||
visible: !!el.offsetParent
|
||||
})).filter(r => r.text)
|
||||
return JSON.stringify(rows.slice(0, 30), null, 1)
|
||||
})()`)
|
||||
|
||||
console.log(out)
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Session-switch autopsy: click between the two heaviest rows repeatedly,
|
||||
// recording per-switch (a) settled ms, (b) React commits, (c) top rendered
|
||||
// components — so slow switches name themselves.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const arg = (name, fallback) => {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
const port = Number(arg('port', 9222))
|
||||
const ROUNDS = Number(arg('rounds', 8))
|
||||
|
||||
const { cdp, teardown } = await attach({ port })
|
||||
|
||||
const SWITCH_ONE = index => `
|
||||
(async () => {
|
||||
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
|
||||
if (rows.length < 2) return JSON.stringify({ error: 'rows' })
|
||||
const row = rows[${index} % 2]
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
rc.start()
|
||||
const t0 = performance.now()
|
||||
row.click()
|
||||
let lastMutation = performance.now()
|
||||
const mo = new MutationObserver(() => { lastMutation = performance.now() })
|
||||
mo.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||
const deadline = performance.now() + 4000
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
if (performance.now() - lastMutation > 150) break
|
||||
}
|
||||
mo.disconnect()
|
||||
rc.stop()
|
||||
const settled = Math.round(performance.now() - t0 - 150)
|
||||
const report = rc.report(6).map(r => r.name + ':' + r.renders + '(' + Math.round(r.totalMs) + 'ms)')
|
||||
return JSON.stringify({ label: (row.textContent ?? '').slice(0, 24), settled, commits: rc.commits(), top: report })
|
||||
})()
|
||||
`
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
for (let i = 0; i < ROUNDS; i++) {
|
||||
console.log(await cdp.eval(SWITCH_ONE(i)))
|
||||
await sleep(400)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// What happens during a SLOW session switch? Click a heavy row with tracing
|
||||
// on, dump the style/layout/script split plus top callsites.
|
||||
import { attach } from './perf/lib/launch.mjs'
|
||||
import { sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const { cdp, teardown } = await attach({ port: 9222 })
|
||||
|
||||
const CLICK_HEAVIEST = `
|
||||
(() => {
|
||||
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
|
||||
if (rows.length < 2) return 'need rows'
|
||||
// Alternate between the first two rows so every run actually switches.
|
||||
const current = location.hash
|
||||
const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1]
|
||||
target.click()
|
||||
return 'clicked: ' + (target.textContent ?? '').slice(0, 40)
|
||||
})()
|
||||
`
|
||||
|
||||
const events = []
|
||||
let complete = false
|
||||
cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? [])))
|
||||
cdp.on('Tracing.tracingComplete', () => {
|
||||
complete = true
|
||||
})
|
||||
|
||||
try {
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
await cdp.send('Tracing.start', {
|
||||
transferMode: 'ReportEvents',
|
||||
traceConfig: { includedCategories: ['devtools.timeline'] }
|
||||
})
|
||||
|
||||
console.log(await cdp.eval(CLICK_HEAVIEST))
|
||||
await sleep(2500)
|
||||
console.log(await cdp.eval(CLICK_HEAVIEST))
|
||||
await sleep(2500)
|
||||
|
||||
await cdp.send('Tracing.end')
|
||||
|
||||
for (let w = 0; !complete && w < 10000; w += 200) {
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
const totals = new Map()
|
||||
const byFn = new Map()
|
||||
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || typeof e.dur !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000)
|
||||
|
||||
if (e.name === 'FunctionCall') {
|
||||
const d = e.args?.data ?? {}
|
||||
const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
|
||||
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const style = totals.get('UpdateLayoutTree') ?? 0
|
||||
const layout = totals.get('Layout') ?? 0
|
||||
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
|
||||
console.log(`\nVERDICT over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`)
|
||||
console.log('\nTOP CALLSITES:')
|
||||
|
||||
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) {
|
||||
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
|
||||
}
|
||||
} finally {
|
||||
teardown?.()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Simple eval helper — runs an expression and returns the result.value.
|
||||
//
|
||||
// node scripts/eval.mjs "document.title"
|
||||
// HERMES_DESKTOP_CDP_PORT=9333 node scripts/eval.mjs "document.title"
|
||||
//
|
||||
// Needs a renderer with a debugging port: launch `hgui` / `npm run dev` with
|
||||
// HERMES_DESKTOP_CDP_PORT set (see electron/dev-cdp.ts).
|
||||
const port = Number(process.env.HERMES_DESKTOP_CDP_PORT || 9222)
|
||||
let targets
|
||||
|
||||
try {
|
||||
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
|
||||
} catch {
|
||||
console.error(
|
||||
`no renderer debugging port on 127.0.0.1:${port}. ` +
|
||||
'Dev-server runs (`npm run dev` / `hgui`) open one automatically — check the app is running, ' +
|
||||
'and that HERMES_DESKTOP_CDP_PORT is not set to "off" or another port.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const t = targets.find((t) => t.url.includes('5174')) ?? targets.find((t) => t.type === 'page')
|
||||
|
||||
if (!t) {
|
||||
console.error(`no page target on 127.0.0.1:${port} (found ${targets.length} target(s))`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(t.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) }
|
||||
})
|
||||
await new Promise((r) => ws.addEventListener('open', r))
|
||||
const send = (method, params) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })) })
|
||||
|
||||
const expr = process.argv[2] || '1+1'
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
if (r.result.exceptionDetails) {
|
||||
console.error('EXCEPTION:', r.result.exceptionDetails.exception?.description)
|
||||
} else {
|
||||
console.log(JSON.stringify(r.result.result.value, null, 2))
|
||||
}
|
||||
ws.close()
|
||||
@@ -0,0 +1,171 @@
|
||||
// Throwaway generator: deterministic fake star-map graphs → real share codes
|
||||
// (runs the actual encoder, so every string round-trips). Run with `npx tsx`.
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
import type { StarmapEdge, StarmapGraph, StarmapMemoryCard, StarmapNode } from '../src/types/hermes'
|
||||
|
||||
import { decodeShareCode, encodeShareCode } from '../src/app/starmap/share-code'
|
||||
|
||||
const DAY = 86_400
|
||||
const END = Math.floor(Date.UTC(2026, 5, 29) / 1000)
|
||||
|
||||
// mulberry32 — tiny seeded PRNG so the output is byte-stable across runs.
|
||||
const rng = (seed: number) => () => {
|
||||
seed |= 0
|
||||
seed = (seed + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296
|
||||
}
|
||||
|
||||
const pick = <T>(arr: readonly T[], r: number): T => arr[Math.floor(r * arr.length)]!
|
||||
|
||||
const CATEGORIES = ['devops', 'research', 'creative', 'security', 'mlops', 'blockchain', 'email', 'health', 'web-development', 'comms'] as const
|
||||
const STATES = ['active', 'active', 'active', 'archived', 'draft', 'disabled'] as const
|
||||
const CREATED = [null, 'agent', 'agent', 'user'] as const
|
||||
|
||||
const skill = (id: string, label: string, ts: number, r: () => number): StarmapNode => ({
|
||||
category: pick(CATEGORIES, r()),
|
||||
createdBy: pick(CREATED, r()),
|
||||
id,
|
||||
kind: 'skill',
|
||||
label,
|
||||
pinned: r() > 0.85,
|
||||
state: pick(STATES, r()),
|
||||
timestamp: ts,
|
||||
useCount: Math.floor(r() ** 3 * 120)
|
||||
})
|
||||
|
||||
const memNode = (i: number, source: 'memory' | 'profile', label: string, ts: null | number): StarmapNode => ({
|
||||
category: 'memory',
|
||||
createdBy: 'memory',
|
||||
id: `memory:${source}:${i}`,
|
||||
kind: 'memory',
|
||||
label,
|
||||
memorySource: source,
|
||||
pinned: false,
|
||||
state: 'active',
|
||||
timestamp: ts,
|
||||
useCount: 0
|
||||
})
|
||||
|
||||
const card = (source: 'memory' | 'profile', title: string, body: string, ts: null | number): StarmapMemoryCard => ({ body, source, timestamp: ts, title })
|
||||
|
||||
// ── 1. Tiny + quirky ──────────────────────────────────────────────────────────
|
||||
function tiny(): StarmapGraph {
|
||||
const r = rng(7)
|
||||
const nodes: StarmapNode[] = [
|
||||
skill('summon-coffee', 'Summon Coffee', END - 40 * DAY, r),
|
||||
skill('rubber-duck', 'Rubber-Duck Debugging', END - 22 * DAY, r),
|
||||
skill('git-blame-zen', 'Git Blame Without Rage', END - 9 * DAY, r),
|
||||
memNode(0, 'profile', 'Prefers tabs, dies on this hill', END - 30 * DAY),
|
||||
memNode(1, 'memory', 'The prod incident of last Tuesday', END - 3 * DAY)
|
||||
]
|
||||
const edges: StarmapEdge[] = [
|
||||
{ source: 'memory:memory:1', target: 'git-blame-zen' },
|
||||
{ source: 'rubber-duck', target: 'git-blame-zen' }
|
||||
]
|
||||
const memory = [
|
||||
card('profile', 'Prefers tabs, dies on this hill', 'Tabs over spaces. Non-negotiable.', END - 30 * DAY),
|
||||
card('memory', 'The prod incident of last Tuesday', 'Never deploy on a Friday again.', END - 3 * DAY)
|
||||
]
|
||||
|
||||
return { clusters: [], edges, memory, nodes, stats: {} }
|
||||
}
|
||||
|
||||
// ── 2. Mid-size, mixed signal ────────────────────────────────────────────────
|
||||
function mid(): StarmapGraph {
|
||||
const r = rng(42)
|
||||
const names = ['Kubernetes Whispering', 'Prompt Surgery', 'Threat Modeling', 'Pixel Pushing', 'Vector Janitor', 'Smart-Contract Audit', 'Inbox Zero Ops', 'Sleep Debt Tracker', 'SSR Hydration', 'Standup Telepathy', 'Flaky-Test Exorcism', 'Cost Spelunking']
|
||||
const nodes: StarmapNode[] = names.map((label, i) => skill(`s${i}`, label, END - Math.floor(r() * 200) * DAY, r))
|
||||
const memTitles = ['Hates meetings before noon', 'Lives in us-east-1', 'Allergic to YAML', 'Caffeine half-life ~5h', 'Reviews in dark mode']
|
||||
|
||||
memTitles.forEach((title, i) => {
|
||||
const ts = END - Math.floor(r() * 120) * DAY
|
||||
nodes.push(memNode(i, i % 2 ? 'memory' : 'profile', title, ts))
|
||||
})
|
||||
|
||||
const edges: StarmapEdge[] = []
|
||||
|
||||
for (let i = 0; i < 9; i += 1) {
|
||||
edges.push({ source: `s${Math.floor(r() * names.length)}`, target: `s${Math.floor(r() * names.length)}` })
|
||||
}
|
||||
|
||||
const memory = memTitles.map((title, i) => card(i % 2 ? 'memory' : 'profile', title, `${title}. Logged automatically.`, END - Math.floor(rng(99 + i)() * 120) * DAY))
|
||||
|
||||
return { clusters: [], edges, memory, nodes, stats: {} }
|
||||
}
|
||||
|
||||
// ── 3. Dense web, partly undated (ordinal fallback) ──────────────────────────
|
||||
function web(): StarmapGraph {
|
||||
const r = rng(1337)
|
||||
const nodes: StarmapNode[] = Array.from({ length: 22 }, (_, i) =>
|
||||
// Half the skills carry no timestamp → exercises the ordinal recency path.
|
||||
skill(`w${i}`, `Neuron ${String.fromCharCode(65 + (i % 26))}${i}`, i % 2 ? END - Math.floor(r() * 300) * DAY : (null as unknown as number), r)
|
||||
)
|
||||
const edges: StarmapEdge[] = []
|
||||
|
||||
for (let i = 0; i < 44; i += 1) {
|
||||
edges.push({ source: `w${Math.floor(r() * 22)}`, target: `w${Math.floor(r() * 22)}` })
|
||||
}
|
||||
|
||||
return { clusters: [], edges, memory: [], nodes, stats: {} }
|
||||
}
|
||||
|
||||
// ── 4. The beast: ~2 years, hundreds of nodes, bursty timeline ───────────────
|
||||
function beast(): StarmapGraph {
|
||||
const r = rng(2024)
|
||||
const start = END - 730 * DAY
|
||||
const span = END - start
|
||||
const nodes: StarmapNode[] = []
|
||||
const memory: StarmapMemoryCard[] = []
|
||||
|
||||
// Bursts → an interesting waveform instead of a flat smear.
|
||||
const burstAt = (q: number) => Math.floor(start + (q + (r() - 0.5) * 0.06) * span)
|
||||
|
||||
for (let i = 0; i < 240; i += 1) {
|
||||
const burst = Math.floor(r() ** 1.5 * 12) / 12 // cluster toward the recent end
|
||||
nodes.push(skill(`b${i}`, `Skill ${i} · ${pick(CATEGORIES, r())}`, burstAt(burst), r))
|
||||
}
|
||||
|
||||
for (let i = 0; i < 150; i += 1) {
|
||||
const ts = burstAt(Math.floor(r() ** 1.5 * 12) / 12)
|
||||
const source = r() > 0.5 ? 'memory' : 'profile'
|
||||
nodes.push(memNode(i, source, `Memory ${i}: ${pick(['quirk', 'fact', 'preference', 'incident', 'lesson'], r())}`, ts))
|
||||
memory.push(card(source, `Memory ${i}`, `Auto-captured note #${i}.`, ts))
|
||||
}
|
||||
|
||||
const edges: StarmapEdge[] = []
|
||||
|
||||
for (let i = 0; i < 380; i += 1) {
|
||||
const a = Math.floor(r() * 240)
|
||||
const b = Math.floor(r() * 240)
|
||||
|
||||
if (a !== b) {
|
||||
edges.push({ source: `b${a}`, target: `b${b}` })
|
||||
}
|
||||
}
|
||||
|
||||
return { clusters: [], edges, memory, nodes, stats: {} }
|
||||
}
|
||||
|
||||
const graphs: [string, StarmapGraph][] = [
|
||||
['tiny + quirky', tiny()],
|
||||
['mid · mixed signal', mid()],
|
||||
['dense web · half undated', web()],
|
||||
['the beast · ~2 years', beast()]
|
||||
]
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
for (const [name, g] of graphs) {
|
||||
const code = encodeShareCode(g)
|
||||
const back = decodeShareCode(code) // round-trip assert — throws if invalid
|
||||
// v2 is viz-only: nodes + edge topology survive; memory prose is dropped.
|
||||
const ok = back.nodes.length === g.nodes.length && back.edges.length <= g.edges.length
|
||||
console.log(`${ok ? 'ok ' : 'BAD'} ${name} — ${g.nodes.length} nodes / ${g.edges.length} edges / ${g.memory.length} cards (${code.length} chars)`)
|
||||
lines.push(`# ${name} — ${g.nodes.length} nodes, ${g.edges.length} edges, ${g.memory.length} cards`, code, '')
|
||||
}
|
||||
|
||||
writeFileSync(new URL('share-codes.txt', import.meta.url), lines.join('\n'))
|
||||
@@ -0,0 +1,290 @@
|
||||
// Live-drive harness for the REAL hgui instance on :9222.
|
||||
//
|
||||
// node scripts/live-drive.mjs status — targets, session count, perf-live armed?
|
||||
// node scripts/live-drive.mjs fps [seconds] — raw rAF fps over N seconds (default 4)
|
||||
// node scripts/live-drive.mjs drag — drag the sidebar sash, report fps + LoAF
|
||||
// node scripts/live-drive.mjs type — type into composer, report fps + LoAF
|
||||
// node scripts/live-drive.mjs switch — cycle through sidebar sessions, per-switch ms
|
||||
// node scripts/live-drive.mjs send "msg" — submit a prompt in the focused session
|
||||
// node scripts/live-drive.mjs eval "expr" — arbitrary page eval
|
||||
//
|
||||
// Attaches to the page target directly (no perf-harness deps) so it works on
|
||||
// the app Brooklyn actually runs, with her profile, her sessions, her layout.
|
||||
|
||||
import { WebSocket } from 'ws'
|
||||
|
||||
const PORT = Number(process.env.CDP_PORT ?? 9222)
|
||||
|
||||
async function attach() {
|
||||
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
|
||||
const page = list.find(t => t.type === 'page' && !/devtools/.test(t.url))
|
||||
|
||||
if (!page) {
|
||||
throw new Error('no page target on :' + PORT)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 })
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.once('open', resolve)
|
||||
ws.once('error', reject)
|
||||
})
|
||||
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.on('message', raw => {
|
||||
const msg = JSON.parse(raw)
|
||||
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result)
|
||||
}
|
||||
})
|
||||
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const mid = ++id
|
||||
pending.set(mid, { resolve, reject })
|
||||
ws.send(JSON.stringify({ id: mid, method, params }))
|
||||
})
|
||||
|
||||
await send('Runtime.enable')
|
||||
|
||||
const evaluate = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
|
||||
|
||||
if (r.exceptionDetails) {
|
||||
throw new Error(r.exceptionDetails.exception?.description ?? 'eval failed')
|
||||
}
|
||||
|
||||
return r.result?.value
|
||||
}
|
||||
|
||||
return { evaluate, close: () => ws.close(), send }
|
||||
}
|
||||
|
||||
const FPS = seconds => `
|
||||
(async () => {
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
const end = last + ${seconds * 1000}
|
||||
while (performance.now() < end) {
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
}
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
|
||||
return {
|
||||
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
|
||||
p95: Math.round(pct(0.95) * 10) / 10,
|
||||
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
n: frames.length
|
||||
}
|
||||
})()
|
||||
`
|
||||
|
||||
// LoAF recorder for a window of work driven inside `body`.
|
||||
const WITH_LOAF = body => `
|
||||
(async () => {
|
||||
const lofs = []
|
||||
const po = new PerformanceObserver(list => {
|
||||
for (const e of list.getEntries()) {
|
||||
lofs.push({
|
||||
ms: Math.round(e.duration),
|
||||
block: Math.round(e.blockingDuration ?? 0),
|
||||
style: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0,
|
||||
scripts: (e.scripts ?? []).filter(s => s.duration >= 5).map(s =>
|
||||
(s.invokerType ?? '') + ':' + (s.invoker ?? s.sourceFunctionName ?? '?') + '@' +
|
||||
((s.sourceURL ?? '').split('/').pop() ?? '') + ' ' + Math.round(s.duration) + 'ms')
|
||||
})
|
||||
}
|
||||
})
|
||||
po.observe({ type: 'long-animation-frame', buffered: false })
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
let stop = false
|
||||
const tick = () => {
|
||||
if (stop) return
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
${body}
|
||||
stop = true
|
||||
po.disconnect()
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
|
||||
return {
|
||||
fps: total ? Math.round((frames.length / total) * 1000 * 10) / 10 : 0,
|
||||
p95: Math.round(pct(0.95) * 10) / 10,
|
||||
worst: sorted.length ? Math.round(sorted[sorted.length - 1] * 10) / 10 : 0,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
longFrames: lofs.slice(0, 10)
|
||||
}
|
||||
})()
|
||||
`
|
||||
|
||||
const DRAG_BODY = `
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
if (!handle) throw new Error('no sash')
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
for (let i = 0; i < 60; i++) {
|
||||
x += (i < 30 ? 3 : -3)
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
`
|
||||
|
||||
const TYPE_BODY = `
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
|
||||
if (!el) throw new Error('no composer')
|
||||
el.focus()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ch = 'the quick brown fox '[i % 20]
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
}
|
||||
`
|
||||
|
||||
// Session switching: click each sidebar session row, time route->settled.
|
||||
const SWITCH = `
|
||||
(async () => {
|
||||
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')]
|
||||
.filter(el => el.offsetParent !== null).slice(0, 8)
|
||||
if (pick().length < 2) {
|
||||
throw new Error('found ' + pick().length + ' session rows')
|
||||
}
|
||||
const times = []
|
||||
const labels = []
|
||||
for (let i = 0; i < Math.min(pick().length, 6); i++) {
|
||||
// Re-query each iteration: a switch can re-render the sidebar and
|
||||
// detach the previously captured nodes.
|
||||
const row = pick()[i]
|
||||
if (!row) break
|
||||
labels.push((row.textContent || '').slice(0, 24))
|
||||
const t0 = performance.now()
|
||||
row.click()
|
||||
let calm = 0
|
||||
while (calm < 2 && performance.now() - t0 < 5000) {
|
||||
const f0 = performance.now()
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
const dt = performance.now() - f0
|
||||
calm = dt < 20 ? calm + 1 : 0
|
||||
}
|
||||
times.push(Math.round(performance.now() - t0))
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
}
|
||||
return { switches: times, labels, avg: Math.round(times.reduce((a, b) => a + b, 0) / times.length) }
|
||||
})()
|
||||
`
|
||||
|
||||
const cmd = process.argv[2] ?? 'status'
|
||||
const arg = process.argv[3]
|
||||
const { evaluate, close } = await attach()
|
||||
|
||||
try {
|
||||
if (cmd === 'status') {
|
||||
const r = await evaluate(`JSON.stringify({
|
||||
url: location.hash,
|
||||
perfLive: typeof window.__PERF_LIVE__ !== 'undefined',
|
||||
renderCounts: typeof window.__RENDER_COUNTS__ !== 'undefined',
|
||||
tiles: document.querySelectorAll('[data-tree-group]').length,
|
||||
sessions: document.querySelectorAll('[data-slot*="session-row"], [data-session-row]').length,
|
||||
composers: [...document.querySelectorAll('[contenteditable="true"]')].length
|
||||
})`)
|
||||
console.log(r)
|
||||
} else if (cmd === 'fps') {
|
||||
console.log(JSON.stringify(await evaluate(FPS(Number(arg ?? 4)))))
|
||||
} else if (cmd === 'drag') {
|
||||
const r = await evaluate(WITH_LOAF(DRAG_BODY))
|
||||
console.log('drag', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
|
||||
for (const lf of r.longFrames) {
|
||||
console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
|
||||
}
|
||||
} else if (cmd === 'type') {
|
||||
const r = await evaluate(WITH_LOAF(TYPE_BODY))
|
||||
console.log('type', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
|
||||
for (const lf of r.longFrames) {
|
||||
console.log(` ⏱ ${lf.ms}ms block=${lf.block} style=${lf.style} → ${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
|
||||
}
|
||||
} else if (cmd === 'switch') {
|
||||
console.log(JSON.stringify(await evaluate(SWITCH)))
|
||||
} else if (cmd === 'eval') {
|
||||
console.log(JSON.stringify(await evaluate(arg)))
|
||||
} else if (cmd === 'profile') {
|
||||
// CPU-profile one session switch via CDP Profiler (Document Policy blocks
|
||||
// the in-page Profiler API, CDP is exempt). arg = row label prefix.
|
||||
await send('Profiler.enable')
|
||||
await send('Profiler.setSamplingInterval', { interval: 200 })
|
||||
await send('Profiler.start')
|
||||
const r = await evaluate(`
|
||||
(async () => {
|
||||
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent !== null)
|
||||
const row = pick().find(el => (el.textContent || '').startsWith(${JSON.stringify(arg ?? 'GUI')}))
|
||||
if (!row) return 'row not found'
|
||||
const t0 = performance.now()
|
||||
row.click()
|
||||
let calm = 0
|
||||
while (calm < 3 && performance.now() - t0 < 6000) {
|
||||
const f0 = performance.now()
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
calm = (performance.now() - f0) < 20 ? calm + 1 : 0
|
||||
}
|
||||
return Math.round(performance.now() - t0)
|
||||
})()
|
||||
`)
|
||||
const { profile } = await send('Profiler.stop')
|
||||
// Self-time per function.
|
||||
const hitById = new Map()
|
||||
for (let i = 0; i < profile.samples.length; i++) {
|
||||
const id = profile.samples[i]
|
||||
const dt = profile.timeDeltas[i] ?? 0
|
||||
hitById.set(id, (hitById.get(id) ?? 0) + dt)
|
||||
}
|
||||
const rows = []
|
||||
for (const node of profile.nodes) {
|
||||
const us = hitById.get(node.id)
|
||||
if (!us || us < 5000) continue
|
||||
const f = node.callFrame
|
||||
rows.push([Math.round(us / 1000), `${f.functionName || '(anon)'} @ ${(f.url || '').split('/').pop()}:${f.lineNumber}`])
|
||||
}
|
||||
rows.sort((a, b) => b[0] - a[0])
|
||||
console.log('switch took', r, 'ms — top self-time:')
|
||||
for (const [ms, name] of rows.slice(0, 18)) {
|
||||
console.log(` ${String(ms).padStart(6)}ms ${name}`)
|
||||
}
|
||||
} else if (cmd === 'send') {
|
||||
const r = await evaluate(`
|
||||
(async () => {
|
||||
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
|
||||
if (!el) return 'no composer'
|
||||
el.focus()
|
||||
document.execCommand('insertText', false, ${JSON.stringify(arg ?? 'hello')})
|
||||
await new Promise(r => setTimeout(r, 120))
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' }))
|
||||
return 'sent'
|
||||
})()
|
||||
`)
|
||||
console.log(r)
|
||||
} else {
|
||||
console.log('unknown cmd', cmd)
|
||||
}
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Regression #87758: a local desktop pack must never enter electron-builder's
|
||||
* publish path, and publish resolution must succeed when something else does
|
||||
* enter it.
|
||||
*
|
||||
* These call the real app-builder-lib resolver rather than asserting on the
|
||||
* text of the pack script, so they fail if electron-builder changes the
|
||||
* behavior we depend on — not when someone reformats package.json.
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, beforeEach, describe, test } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const desktopPkg = require(path.join(desktopDir, 'package.json'))
|
||||
|
||||
const { getPublishConfigs } = require('app-builder-lib/out/publish/PublishManager.js')
|
||||
const { getRepositoryInfo } = require('app-builder-lib/out/util/repositoryInfo.js')
|
||||
|
||||
/**
|
||||
* The slice of PlatformPackager that getPublishConfigs actually reads. The
|
||||
* repositoryInfo getter mirrors what electron-builder does for real: resolve
|
||||
* from THIS package's metadata with projectDir = apps/desktop.
|
||||
*/
|
||||
function fakePackager(metadata) {
|
||||
const info = {
|
||||
get repositoryInfo() {
|
||||
return getRepositoryInfo(desktopDir, metadata, null)
|
||||
},
|
||||
appInfo: { version: '0.0.0', channel: null, updaterCacheDirName: 'hermes' },
|
||||
config: {},
|
||||
options: {}
|
||||
}
|
||||
return {
|
||||
platformSpecificBuildOptions: {},
|
||||
config: {},
|
||||
info,
|
||||
platform: { name: 'linux' },
|
||||
appInfo: info.appInfo,
|
||||
expandMacro: value => value
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_VARS = ['GH_TOKEN', 'GITHUB_TOKEN', 'GITLAB_TOKEN', 'KEYGEN_TOKEN', 'BITBUCKET_TOKEN']
|
||||
let savedEnv
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = {}
|
||||
for (const key of TOKEN_VARS) {
|
||||
savedEnv[key] = process.env[key]
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of TOKEN_VARS) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = savedEnv[key]
|
||||
}
|
||||
})
|
||||
|
||||
describe('local desktop pack stays out of the publish path', () => {
|
||||
test('the pack script pins an explicit publish policy', () => {
|
||||
// electron-builder 26 infers `onTagOrDraft` from CI when --publish is
|
||||
// absent, and `hermes desktop` runs the pack with CI=1 (_npm_lifecycle_env).
|
||||
// v27 drops the implicit behavior, so being explicit is also forward-safe.
|
||||
const pack = desktopPkg.scripts.pack
|
||||
assert.match(pack, /--dir\b/)
|
||||
assert.match(pack, /--publish\s+never\b/)
|
||||
})
|
||||
|
||||
test('publish resolution succeeds with a GitHub token present', async () => {
|
||||
// The #87758 failure mode: CI=1 makes isPublish true, a GITHUB_TOKEN in the
|
||||
// environment auto-selects the github provider, and the provider needs a
|
||||
// repository it cannot find — apps/desktop has no .git/config of its own
|
||||
// and app-builder-lib does not walk up to the workspace root.
|
||||
process.env.GITHUB_TOKEN = 'x'
|
||||
|
||||
const configs = await getPublishConfigs(
|
||||
fakePackager(desktopPkg),
|
||||
null,
|
||||
null,
|
||||
/* errorIfCannot */ true
|
||||
)
|
||||
|
||||
assert.ok(Array.isArray(configs) && configs.length > 0)
|
||||
assert.equal(configs[0].provider, 'github')
|
||||
assert.equal(configs[0].owner, 'NousResearch')
|
||||
assert.equal(configs[0].repo, 'hermes-agent')
|
||||
})
|
||||
|
||||
test('a package without the repository field is what breaks resolution', async () => {
|
||||
// Guards the fix itself: proves the assertion above passes because of the
|
||||
// repository field, not because the throw is unreachable.
|
||||
process.env.GITHUB_TOKEN = 'x'
|
||||
const { repository, ...withoutRepository } = desktopPkg
|
||||
assert.ok(repository, 'apps/desktop/package.json must declare a repository')
|
||||
|
||||
await assert.rejects(
|
||||
() => getPublishConfigs(fakePackager(withoutRepository), null, null, true),
|
||||
/Cannot detect repository by \.git\/config/
|
||||
)
|
||||
})
|
||||
|
||||
test('resolution is quiet when no publish token is configured', async () => {
|
||||
const configs = await getPublishConfigs(fakePackager(desktopPkg), null, null, true)
|
||||
assert.deepEqual(configs, [])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { existsSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
function run(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
// Intentionally omit args from the rejection message: callers pass
|
||||
// notarization credentials (key id, issuer, key file path) here, and
|
||||
// surfacing them in error output would land in CI logs.
|
||||
reject(new Error(`${command} failed: ${stderr?.trim() || stdout?.trim() || error.message}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function inlineKeyLooksValid(value) {
|
||||
return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY')
|
||||
}
|
||||
|
||||
function resolveApiKeyPath(rawValue) {
|
||||
const value = String(rawValue || '').trim()
|
||||
if (!value) return { keyPath: '', cleanup: () => {} }
|
||||
|
||||
if (existsSync(value)) {
|
||||
return { keyPath: value, cleanup: () => {} }
|
||||
}
|
||||
|
||||
if (!inlineKeyLooksValid(value)) {
|
||||
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
|
||||
}
|
||||
|
||||
const tempPath = join(tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
|
||||
writeFileSync(tempPath, value, 'utf8')
|
||||
return {
|
||||
keyPath: tempPath,
|
||||
cleanup: () => rmSync(tempPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const artifactPath = process.argv[2]
|
||||
if (!artifactPath || !existsSync(artifactPath)) {
|
||||
throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`)
|
||||
}
|
||||
|
||||
const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim()
|
||||
if (profile) {
|
||||
await run('xcrun', ['notarytool', 'submit', artifactPath, '--keychain-profile', profile, '--wait'])
|
||||
await run('xcrun', ['stapler', 'staple', '-v', artifactPath])
|
||||
return
|
||||
}
|
||||
|
||||
const keyId = String(process.env.APPLE_API_KEY_ID || '').trim()
|
||||
const issuer = String(process.env.APPLE_API_ISSUER || '').trim()
|
||||
const rawApiKey = process.env.APPLE_API_KEY
|
||||
if (!rawApiKey || !keyId || !issuer) {
|
||||
throw new Error('APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are required')
|
||||
}
|
||||
|
||||
const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey)
|
||||
try {
|
||||
await run('xcrun', ['notarytool', 'submit', artifactPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait'])
|
||||
await run('xcrun', ['stapler', 'staple', '-v', artifactPath])
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
console.error('Notarization failed. Check configuration and command output in secure CI logs.')
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
function run(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(
|
||||
new Error(
|
||||
`${command} ${args.join(' ')} failed: ${stderr?.trim() || stdout?.trim() || error.message}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve({ stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function inlineKeyLooksValid(value) {
|
||||
return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY')
|
||||
}
|
||||
|
||||
function resolveApiKeyPath(rawValue) {
|
||||
const value = String(rawValue || '').trim()
|
||||
if (!value) return { keyPath: '', cleanup: () => {} }
|
||||
|
||||
if (fs.existsSync(value)) {
|
||||
return { keyPath: value, cleanup: () => {} }
|
||||
}
|
||||
|
||||
if (!inlineKeyLooksValid(value)) {
|
||||
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
|
||||
}
|
||||
|
||||
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
|
||||
fs.writeFileSync(tempPath, value, 'utf8')
|
||||
return {
|
||||
keyPath: tempPath,
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async function notarize(context) {
|
||||
const { electronPlatformName, appOutDir, packager } = context
|
||||
if (electronPlatformName !== 'darwin') return
|
||||
|
||||
const appName = packager.appInfo.productFilename
|
||||
const appPath = path.join(appOutDir, `${appName}.app`)
|
||||
if (!fs.existsSync(appPath)) {
|
||||
throw new Error(`Cannot notarize missing app bundle: ${appPath}`)
|
||||
}
|
||||
|
||||
const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim()
|
||||
if (profile) {
|
||||
const zipPath = path.join(appOutDir, `${appName}.zip`)
|
||||
await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath])
|
||||
await run('xcrun', ['notarytool', 'submit', zipPath, '--keychain-profile', profile, '--wait'])
|
||||
await run('xcrun', ['stapler', 'staple', '-v', appPath])
|
||||
try {
|
||||
fs.rmSync(zipPath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const keyId = String(process.env.APPLE_API_KEY_ID || '').trim()
|
||||
const issuer = String(process.env.APPLE_API_ISSUER || '').trim()
|
||||
const rawApiKey = process.env.APPLE_API_KEY
|
||||
if (!rawApiKey || !keyId || !issuer) {
|
||||
console.log(
|
||||
'Skipping notarization: APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are not fully configured.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey)
|
||||
const zipPath = path.join(appOutDir, `${appName}.zip`)
|
||||
try {
|
||||
await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath])
|
||||
await run('xcrun', ['notarytool', 'submit', zipPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait'])
|
||||
await run('xcrun', ['stapler', 'staple', '-v', appPath])
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(zipPath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const desktopRoot = path.resolve(import.meta.dirname, '..')
|
||||
const repoRoot = path.resolve(desktopRoot, '..', '..')
|
||||
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')
|
||||
|
||||
const marker = 'hermes-macos-electron-binary-fallback'
|
||||
const needle = ` await Promise.all([
|
||||
doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
|
||||
]);`
|
||||
const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy
|
||||
// Electron.app without its main MacOS/Electron binary before this rename.
|
||||
// Restore it from the installed Electron runtime so local desktop installs
|
||||
// do not fail with ENOENT during macOS arm64 packaging.
|
||||
const macosDir = path.join(contentsPath, "MacOS");
|
||||
const bundledElectronBinary = path.join(macosDir, electronBranding.productName);
|
||||
if (!fs.existsSync(bundledElectronBinary)) {
|
||||
const candidates = [
|
||||
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
|
||||
// npm may nest the workspace-only electron devDep under
|
||||
// apps/desktop/node_modules (process.cwd() during pack), or hoist
|
||||
// it to the repo root. Try the workspace-local install first, then
|
||||
// the root hoist, so the fallback works under either layout.
|
||||
path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
||||
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
||||
];
|
||||
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
|
||||
if (sourceBinary == null) {
|
||||
throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary);
|
||||
}
|
||||
await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary);
|
||||
await (0, promises_1.chmod)(bundledElectronBinary, 0o755);
|
||||
}
|
||||
await Promise.all([
|
||||
doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
|
||||
]);`
|
||||
|
||||
if (!fs.existsSync(electronMacPath)) {
|
||||
console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(electronMacPath, 'utf8')
|
||||
if (source.includes(marker)) {
|
||||
console.log('[patch-electron-builder] macOS Electron binary fallback already applied')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!source.includes(needle)) {
|
||||
console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
fs.writeFileSync(electronMacPath, source.replace(needle, replacement))
|
||||
console.log('[patch-electron-builder] applied macOS Electron binary fallback')
|
||||
@@ -0,0 +1,93 @@
|
||||
# Desktop perf harness
|
||||
|
||||
One systematized way to measure desktop rendering/interaction performance,
|
||||
diff it against a committed baseline, and fail on regressions. It replaces the
|
||||
dozen one-off `measure-*` / `profile-*` scripts that each reinvented the CDP
|
||||
client, arg parsing, stats, and output (and never had a baseline).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Isolated instance (recommended) — no running app or LLM credits needed.
|
||||
# Its own --user-data-dir + HERMES_HOME means it never collides with `hgui`.
|
||||
npm run perf -- --spawn
|
||||
|
||||
# Or: launch an isolated instance once, attach repeatedly (faster iteration).
|
||||
npm run perf:serve # leaves an instance on :9222
|
||||
npm run perf # attaches, runs the CI suite, gates on baseline
|
||||
|
||||
# One scenario, with a CPU profile:
|
||||
npm run perf -- stream --cpuprofile --tokens 800
|
||||
|
||||
# Representative PRODUCTION numbers (minified React, not the ~3x-slower dev build):
|
||||
npm run perf -- cold-start stream keystroke transcript --spawn --prod
|
||||
|
||||
# Re-capture the baseline on your reference device, then commit baseline.json:
|
||||
npm run perf -- cold-start stream keystroke transcript --spawn --prod --update-baseline
|
||||
```
|
||||
|
||||
## Dev vs prod
|
||||
|
||||
By default the harness measures the **dev** renderer (fast to spin up, good for
|
||||
relative regression checks). Pass `--prod` (with `--spawn`) to build a
|
||||
production renderer *with the probe included* (`VITE_PERF_PROBE=1`) and measure
|
||||
minified React — the representative shipped numbers. The committed baseline is
|
||||
captured with `--prod`.
|
||||
|
||||
## Why isolation matters
|
||||
|
||||
The measurement this harness exists to run was historically blocked: a running
|
||||
`hgui` holds the Electron single-instance lock, so a second instance quit
|
||||
immediately. `--spawn` / `perf:serve` launch with their own `--user-data-dir`
|
||||
(separate lock scope), their own `HERMES_HOME` (separate backend + sessions),
|
||||
and their own `--remote-debugging-port`. Synthetic scenarios drive `$messages`
|
||||
directly via `window.__PERF_DRIVE__`, so no LLM credits are spent.
|
||||
|
||||
## Scenarios
|
||||
|
||||
| scenario | tier | measures | replaces |
|
||||
|---|---|---|---|
|
||||
| `stream` | ci | streaming longtasks, frame p95/p99, mutation cadence | measure-synthetic-stream, profile-synth-stream, profile-long-stream |
|
||||
| `stream --real` | backend | same, from a real LLM stream | measure-real-stream, profile-real-stream |
|
||||
| `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing |
|
||||
| `transcript` | ci | large-transcript mount + paint cost | (new) |
|
||||
| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) |
|
||||
| `idle-cost` | report | busy-but-silent tiles: idle commit rate, + fps while resizing / typing | (new) |
|
||||
| `right-pane` | report | file tree + persistent xterm tabs under chat/terminal output and split dragging | (new) |
|
||||
| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) |
|
||||
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
|
||||
| `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump |
|
||||
| `session-switch` | backend | route → first-paint → settle | profile-session-switch |
|
||||
| `session-load` | backend | how far a session's transcript moves after first paint | (new) |
|
||||
| `profile-switch` | backend | rail click → sidebar settled | measure-profile-switch |
|
||||
|
||||
`ci` + `cold` scenarios need no backend/credits and are gated against
|
||||
`baseline.json` (`cold-start` requires `--spawn` since it measures a fresh
|
||||
launch, and must be run in its own invocation). `backend` scenarios need a live
|
||||
backend (and `--spawn` or a real session/credits) and are report-only.
|
||||
|
||||
CPU profiling is a cross-cutting `--cpuprofile` flag on any scenario (it wraps
|
||||
the run in `Profiler.start/stop` and prints a top-self-time table), replacing
|
||||
every standalone `profile-*` script.
|
||||
|
||||
## Adding a scenario
|
||||
|
||||
Create `scenarios/<name>.mjs` exporting `{ name, tier, description, run(cdp, opts) }`
|
||||
where `run` returns `{ metrics, detail }` (metrics = flat numbers, lower is
|
||||
better), then register it in `scenarios/index.mjs`. If it's `ci`, add a
|
||||
`baseline.json` entry (or run `--update-baseline`).
|
||||
|
||||
## Layout
|
||||
|
||||
- `lib/cdp.mjs` — the one CDP client + target discovery + typing + CPU-profile wrapper + DOM selectors.
|
||||
- `lib/stats.mjs` — percentiles, histograms, CPU-profile self-time ranking.
|
||||
- `lib/baseline.mjs` — load/compare/update the baseline + regression gate.
|
||||
- `lib/launch.mjs` — attach, or spawn a fully isolated instance.
|
||||
- `scenarios/` — one module per measurement.
|
||||
- `run.mjs` — entrypoint. `serve.mjs` — standalone isolated launcher.
|
||||
|
||||
## Not migrated (kept as dev utilities)
|
||||
|
||||
`eval.mjs`, `reload.mjs`, `reload-renderer.mjs`, `probe-renderer.mjs`,
|
||||
`probe-thread.mjs`, `click-session.mjs`, `diag-*.mjs` are interactive dev
|
||||
helpers, not benchmarks. They can adopt `lib/cdp.mjs` in a follow-up.
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"_meta": {
|
||||
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot \u2014 no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
|
||||
"platform": "darwin-arm64",
|
||||
"node": "v24.11.0",
|
||||
"updated": "2026-07-27T00:30:11.290Z"
|
||||
},
|
||||
"scenarios": {
|
||||
"stream": {
|
||||
"tolerance": {
|
||||
"tolFrac": 0.6,
|
||||
"tolAbs": 5
|
||||
},
|
||||
"metrics": {
|
||||
"longtasks_n": 1,
|
||||
"longtask_max_ms": 67,
|
||||
"frame_p95_ms": 22,
|
||||
"frame_p99_ms": 23.7,
|
||||
"slow_frames_33": 1,
|
||||
"intermut_p95_ms": 36.1
|
||||
}
|
||||
},
|
||||
"keystroke": {
|
||||
"tolerance": {
|
||||
"tolFrac": 0.6,
|
||||
"tolAbs": 4
|
||||
},
|
||||
"metrics": {
|
||||
"keystroke_p50_ms": 2.1,
|
||||
"keystroke_p95_ms": 8.7,
|
||||
"keystroke_p99_ms": 16.9,
|
||||
"keystroke_slow_16": 2
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"tolerance": {
|
||||
"tolFrac": 0.75,
|
||||
"tolAbs": 40
|
||||
},
|
||||
"metrics": {
|
||||
"transcript_mount_ms": 145,
|
||||
"transcript_longtask_ms": 82,
|
||||
"transcript_longtask_max_ms": 82
|
||||
}
|
||||
},
|
||||
"cold-start": {
|
||||
"tolerance": {
|
||||
"tolFrac": 0.6,
|
||||
"tolAbs": 150
|
||||
},
|
||||
"metrics": {
|
||||
"spawn_to_cdp_ms": 606,
|
||||
"spawn_to_driver_ms": 984,
|
||||
"dom_interactive_ms": 324,
|
||||
"dom_content_loaded_ms": 574,
|
||||
"nav_to_read_ms": 721
|
||||
}
|
||||
},
|
||||
"multitab": {
|
||||
"metrics": {
|
||||
"longtasks_n": 0,
|
||||
"longtask_max_ms": 0,
|
||||
"frame_p95_ms": 29.1,
|
||||
"frame_p99_ms": 36.2,
|
||||
"slow_frames_33": 9
|
||||
}
|
||||
},
|
||||
"render-churn": {
|
||||
"metrics": {
|
||||
"sidebar_renders": 0,
|
||||
"sidebar_wasted": 0,
|
||||
"wasted_renders": 1704,
|
||||
"total_renders": 8221,
|
||||
"commits": 1352,
|
||||
"wasted_notifies": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Measure the gateway's attach-RPC dispatch, against the real dispatcher.
|
||||
|
||||
Every attach handler (image.attach, image.attach_bytes, file.attach,
|
||||
clipboard.paste, pdf.attach) resolves its session through ``_sess()``, which
|
||||
blocks on the deferred agent build. None of them is in ``_LONG_HANDLERS``, so
|
||||
that block happens INLINE on the socket reader thread.
|
||||
|
||||
This drives the real ``tui_gateway.server.dispatch`` with a session whose
|
||||
agent build has not completed, and times it. ``prompt.submit`` (which uses
|
||||
``_sess_nowait``) is timed alongside as the control — it is the path that
|
||||
stays instant today.
|
||||
|
||||
python3 scripts/perf/gateway_attach_bench.py [--build-seconds 8] [--rounds 3]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[4]
|
||||
sys.path.insert(0, str(REPO))
|
||||
|
||||
os.environ.setdefault("HERMES_HOME", tempfile.mkdtemp(prefix="hermes-bench-home-"))
|
||||
|
||||
|
||||
class CollectTransport:
|
||||
"""Stand-in for the WS transport: records frames, never touches a socket."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.frames: list[dict] = []
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
with self.lock:
|
||||
self.frames.append(obj)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_session(server, sid: str, *, build_seconds: float, home: Path) -> dict:
|
||||
"""A session whose deferred agent build is still running.
|
||||
|
||||
Mirrors the shape ``_deferred_build`` leaves behind: an unset ``agent_ready``
|
||||
event plus a live build thread. That is exactly the state a session is in
|
||||
for the first seconds after ``session.create`` — which is when a user
|
||||
pastes their first image.
|
||||
"""
|
||||
ready = threading.Event()
|
||||
session: dict = {
|
||||
"agent": None,
|
||||
"agent_ready": ready,
|
||||
"agent_error": None,
|
||||
"attached_images": [],
|
||||
"cwd": str(home),
|
||||
"history": [],
|
||||
"history_lock": threading.RLock(),
|
||||
"history_version": 0,
|
||||
"image_counter": 0,
|
||||
"profile_home": str(home),
|
||||
"running": False,
|
||||
"session_key": sid,
|
||||
"transport": None,
|
||||
}
|
||||
|
||||
def build() -> None:
|
||||
time.sleep(build_seconds)
|
||||
ready.set()
|
||||
|
||||
thread = threading.Thread(target=build, daemon=True)
|
||||
session["_agent_build_thread"] = thread
|
||||
thread.start()
|
||||
|
||||
server._sessions[sid] = session
|
||||
return session
|
||||
|
||||
|
||||
def png_bytes(kb: int) -> bytes:
|
||||
body = bytearray(b"\x89PNG\r\n\x1a\n")
|
||||
body.extend(bytes((i * 37) & 0xFF for i in range(kb * 1024)))
|
||||
return bytes(body)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--build-seconds", type=float, default=8.0)
|
||||
ap.add_argument("--rounds", type=int, default=3)
|
||||
ap.add_argument("--kb", type=int, default=900)
|
||||
args = ap.parse_args()
|
||||
|
||||
from tui_gateway import server
|
||||
|
||||
# The build is already in flight for these sessions (that is the state the
|
||||
# bench recreates), so the "start one if none is running" call is a no-op.
|
||||
# Without this stub the real builder races the bench's controlled one and
|
||||
# completes instantly, hiding the very wait being measured.
|
||||
server._start_agent_build = lambda sid, session: None
|
||||
|
||||
# Keep the run readable: session.info frames go to the transport, not stdout.
|
||||
server._emit = lambda *a, **k: None
|
||||
|
||||
home = Path(os.environ["HERMES_HOME"])
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
content_b64 = base64.b64encode(png_bytes(args.kb)).decode("ascii")
|
||||
|
||||
scratch = home / "scratch.txt"
|
||||
scratch.write_text("hello from the bench\n")
|
||||
|
||||
image_on_disk = home / "on-disk.png"
|
||||
image_on_disk.write_bytes(png_bytes(args.kb))
|
||||
|
||||
pdf_on_disk = home / "doc.pdf"
|
||||
pdf_on_disk.write_bytes(b"%PDF-1.4\n" + b"0" * 2048 + b"\n%%EOF\n")
|
||||
|
||||
calls = [
|
||||
(
|
||||
"image.attach_bytes",
|
||||
lambda sid: {
|
||||
"session_id": sid,
|
||||
"content_base64": content_b64,
|
||||
"filename": "bench.png",
|
||||
},
|
||||
),
|
||||
(
|
||||
"image.attach",
|
||||
lambda sid: {"session_id": sid, "path": str(image_on_disk)},
|
||||
),
|
||||
(
|
||||
"file.attach",
|
||||
lambda sid: {
|
||||
"session_id": sid,
|
||||
"name": "scratch.txt",
|
||||
"path": str(scratch),
|
||||
},
|
||||
),
|
||||
(
|
||||
"pdf.attach",
|
||||
lambda sid: {"session_id": sid, "path": str(pdf_on_disk)},
|
||||
),
|
||||
(
|
||||
"clipboard.paste",
|
||||
lambda sid: {"session_id": sid},
|
||||
),
|
||||
(
|
||||
"image.detach",
|
||||
lambda sid: {"session_id": sid, "path": "/tmp/nothing.png"},
|
||||
),
|
||||
(
|
||||
"prompt.submit",
|
||||
lambda sid: {"session_id": sid, "text": "control: plain text"},
|
||||
),
|
||||
]
|
||||
|
||||
print(
|
||||
f"agent build takes {args.build_seconds:.1f}s; "
|
||||
f"image is {args.kb} KB; {args.rounds} rounds\n"
|
||||
)
|
||||
print(f"{'rpc':<22} {'in _LONG_HANDLERS':<19} {'mean':>8} {'max':>8} blocks reader?")
|
||||
|
||||
for method, build_params in calls:
|
||||
samples: list[float] = []
|
||||
|
||||
for round_index in range(args.rounds):
|
||||
sid = f"bench-{method}-{round_index}"
|
||||
make_session(server, sid, build_seconds=args.build_seconds, home=home)
|
||||
transport = CollectTransport()
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": round_index,
|
||||
"method": method,
|
||||
"params": build_params(sid),
|
||||
}
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
server.dispatch(req, transport)
|
||||
except Exception as exc: # noqa: BLE001 - report, don't mask
|
||||
print(f" ! {method} raised {type(exc).__name__}: {exc}")
|
||||
samples.append(time.perf_counter() - start)
|
||||
|
||||
server._sessions.pop(sid, None)
|
||||
|
||||
pooled = method in server._LONG_HANDLERS
|
||||
mean = statistics.mean(samples)
|
||||
worst = max(samples)
|
||||
verdict = "no (pooled)" if pooled else ("YES" if mean > 1.0 else "no")
|
||||
|
||||
print(
|
||||
f"{method:<22} {str(pooled):<19} {mean:>7.2f}s {worst:>7.2f}s {verdict}"
|
||||
)
|
||||
|
||||
print(
|
||||
"\ndispatch() returns immediately for pooled handlers, so a pooled timing\n"
|
||||
"is the enqueue cost — the work still happens, just off the reader thread."
|
||||
)
|
||||
|
||||
_report_surfaces()
|
||||
return 0
|
||||
|
||||
|
||||
def _report_surfaces() -> None:
|
||||
"""Which surfaces can even reach this code path.
|
||||
|
||||
The stall lives in the gateway's session resolver, so a surface is exposed
|
||||
only if it attaches over the gateway. That is a fact about the call graph
|
||||
rather than a timing, so it is read out of the source — and it moves if
|
||||
the call graph moves.
|
||||
"""
|
||||
print("\n\n=== which surfaces reach the gateway attach RPCs ===\n")
|
||||
|
||||
root = Path(__file__).resolve().parents[4]
|
||||
attach_rpcs = ("image.attach", "image.attach_bytes", "file.attach", "clipboard.paste")
|
||||
|
||||
surfaces = {
|
||||
"CLI (cli.py)": [root / "cli.py"],
|
||||
"TUI (ui-tui)": sorted((root / "ui-tui" / "src").rglob("*.ts")),
|
||||
"Desktop (apps/desktop)": sorted((root / "apps" / "desktop" / "src").rglob("*.ts")),
|
||||
}
|
||||
|
||||
for label, paths in surfaces.items():
|
||||
hits: set[str] = set()
|
||||
|
||||
for path in paths:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
for rpc in attach_rpcs:
|
||||
if f"'{rpc}'" in text or f'"{rpc}"' in text:
|
||||
hits.add(rpc)
|
||||
|
||||
if hits:
|
||||
print(f" {label:<24} EXPOSED — calls {', '.join(sorted(hits))}")
|
||||
else:
|
||||
print(f" {label:<24} not exposed — no gateway attach RPC")
|
||||
|
||||
print(
|
||||
"\n CLI attaches inline in its own turn path (cli.py → image_routing) with\n"
|
||||
" the agent already constructed. There is no gateway session to resolve,\n"
|
||||
" so the stall is structurally unreachable — matching the ~4s report.\n"
|
||||
"\n The TUI calls the SAME RPCs and was equally exposed. What differed was\n"
|
||||
" hit rate, not code path: Desktop mints sessions constantly (new chat,\n"
|
||||
" tabs, tiles), so a paste routinely lands inside the seconds-long window\n"
|
||||
" while a fresh session's agent is still building. A TUI user launches\n"
|
||||
" once and the build finishes while they type."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,307 @@
|
||||
// Measures the desktop image-attach pipeline stage by stage on a real image,
|
||||
// against the real renderer helpers. No Electron, no LLM — just the transforms
|
||||
// an attached image goes through between the paperclip and prompt.submit.
|
||||
//
|
||||
// node scripts/perf/image-attach-bench.mjs [--kb 900] [--rounds 7]
|
||||
|
||||
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = (name, fallback) => {
|
||||
const i = args.indexOf(`--${name}`)
|
||||
|
||||
return i >= 0 ? Number(args[i + 1]) : fallback
|
||||
}
|
||||
|
||||
const ROUNDS = flag('rounds', 7)
|
||||
const SIZES_KB = args.includes('--kb') ? [flag('kb', 900)] : [120, 900, 3200]
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-img-bench-'))
|
||||
|
||||
/** A PNG-shaped byte blob of a given size. The pipeline treats it as opaque
|
||||
* bytes everywhere we measure, so the pixels don't matter — the length does. */
|
||||
function makeImage(kb) {
|
||||
const bytes = Buffer.alloc(kb * 1024)
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes)
|
||||
|
||||
for (let i = 8; i < bytes.length; i += 1) {
|
||||
bytes[i] = (i * 2654435761) & 0xff
|
||||
}
|
||||
|
||||
const path = join(dir, `img-${kb}kb.png`)
|
||||
writeFileSync(path, bytes)
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
const stat = samples => {
|
||||
const s = [...samples].sort((a, b) => a - b)
|
||||
|
||||
return {
|
||||
mean: s.reduce((a, b) => a + b, 0) / s.length,
|
||||
p50: s[Math.floor(s.length * 0.5)],
|
||||
p95: s[Math.min(s.length - 1, Math.floor(s.length * 0.95))],
|
||||
max: s[s.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
const time = fn => {
|
||||
const t0 = performance.now()
|
||||
const out = fn()
|
||||
|
||||
return { ms: performance.now() - t0, out }
|
||||
}
|
||||
|
||||
// --- the stages, transcribed from the shipped code paths -------------------
|
||||
|
||||
// electron/hardening.ts :: readFileDataUrlForIpc — main-process side of
|
||||
// window.hermesDesktop.readFileDataUrl.
|
||||
const readFileDataUrl = path => {
|
||||
const data = readFileSync(path)
|
||||
|
||||
return `data:image/png;base64,${data.toString('base64')}`
|
||||
}
|
||||
|
||||
// use-prompt-actions/utils.ts :: base64FromDataUrl
|
||||
const base64FromDataUrl = dataUrl => {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
|
||||
}
|
||||
|
||||
// The JSON-RPC frame the renderer sends for image.attach_bytes.
|
||||
const encodeRpcFrame = (base64, filename) =>
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'image.attach_bytes',
|
||||
params: { session_id: 'bench', content_base64: base64, filename }
|
||||
})
|
||||
|
||||
// lib/embedded-images.ts :: extractEmbeddedImages — runs on the optimistic
|
||||
// bubble text on EVERY DirectiveContent render, and the composer's base64
|
||||
// preview is what it scans.
|
||||
const DATA_IMAGE_PREFIX = 'data:image/'
|
||||
const BASE64_MARKER = ';base64,'
|
||||
const MIN_EMBEDDED_IMAGE_BASE64_LENGTH = 64
|
||||
|
||||
const isImageMimeCode = c =>
|
||||
(c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 43 || c === 45 || c === 46 || c === 95
|
||||
|
||||
const isBase64Code = c =>
|
||||
(c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 43 || c === 47 || c === 61
|
||||
|
||||
function readDataImageUrl(text, start) {
|
||||
if (!text.startsWith(DATA_IMAGE_PREFIX, start)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let cursor = start + DATA_IMAGE_PREFIX.length
|
||||
|
||||
while (cursor < text.length && isImageMimeCode(text.charCodeAt(cursor))) {
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if (cursor === start + DATA_IMAGE_PREFIX.length || !text.startsWith(BASE64_MARKER, cursor)) {
|
||||
return null
|
||||
}
|
||||
|
||||
cursor += BASE64_MARKER.length
|
||||
const base64Start = cursor
|
||||
|
||||
while (cursor < text.length && isBase64Code(text.charCodeAt(cursor))) {
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if (cursor - base64Start < MIN_EMBEDDED_IMAGE_BASE64_LENGTH) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { end: cursor, url: text.slice(start, cursor) }
|
||||
}
|
||||
|
||||
function extractEmbeddedImages(text) {
|
||||
if (!text || !text.includes(DATA_IMAGE_PREFIX)) {
|
||||
return { cleanedText: text, images: [] }
|
||||
}
|
||||
|
||||
const images = []
|
||||
const pieces = []
|
||||
let appendCursor = 0
|
||||
let searchCursor = 0
|
||||
|
||||
while (searchCursor < text.length) {
|
||||
const dataStart = text.indexOf(DATA_IMAGE_PREFIX, searchCursor)
|
||||
|
||||
if (dataStart === -1) {
|
||||
break
|
||||
}
|
||||
|
||||
const dataUrl = readDataImageUrl(text, dataStart)
|
||||
|
||||
if (!dataUrl) {
|
||||
searchCursor = dataStart + DATA_IMAGE_PREFIX.length
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pieces.push(text.slice(appendCursor, dataStart))
|
||||
images.push(dataUrl.url)
|
||||
appendCursor = dataUrl.end
|
||||
searchCursor = dataUrl.end
|
||||
}
|
||||
|
||||
if (!images.length) {
|
||||
return { cleanedText: text, images: [] }
|
||||
}
|
||||
|
||||
pieces.push(text.slice(appendCursor))
|
||||
|
||||
return {
|
||||
cleanedText: pieces
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim(),
|
||||
images
|
||||
}
|
||||
}
|
||||
|
||||
// lib/render-weight.ts :: payloadCharacters — walks every string in a message's
|
||||
// content, including the data URL riding in attachmentRefs.
|
||||
const RENDER_WEIGHT_CHARS = 512
|
||||
const MAX_MEASURED_MESSAGE_CHARS = 300 * RENDER_WEIGHT_CHARS
|
||||
const NON_RENDERED_CONTENT_FIELDS = new Set(['id', 'role', 'toolCallId', 'toolName', 'type'])
|
||||
|
||||
function payloadCharacters(roots, budget) {
|
||||
const seen = new WeakSet()
|
||||
const pending = [...roots]
|
||||
let characters = 0
|
||||
|
||||
while (pending.length > 0 && characters < budget) {
|
||||
const value = pending.pop()
|
||||
|
||||
if (typeof value === 'string') {
|
||||
characters += Math.min(value.length, budget - characters)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object' || seen.has(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(value)
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const nested of value) {
|
||||
pending.push(nested)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (!NON_RENDERED_CONTENT_FIELDS.has(key)) {
|
||||
pending.push(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return characters
|
||||
}
|
||||
|
||||
// store/composer.ts :: cloneDraft — every composer draft stash copies each
|
||||
// attachment object; previewUrl (the data URL) rides along by reference, but
|
||||
// the surrounding string ops on the draft still run.
|
||||
const cloneDraft = draft => ({
|
||||
attachments: draft.attachments.map(a => ({ ...a })),
|
||||
text: draft.text
|
||||
})
|
||||
|
||||
// --- run -------------------------------------------------------------------
|
||||
|
||||
const results = []
|
||||
|
||||
for (const kb of SIZES_KB) {
|
||||
const path = makeImage(kb)
|
||||
const rows = {}
|
||||
const record = (stage, ms) => {
|
||||
;(rows[stage] ??= []).push(ms)
|
||||
}
|
||||
|
||||
let dataUrl = ''
|
||||
let base64 = ''
|
||||
let frame = ''
|
||||
let bubbleText = ''
|
||||
|
||||
for (let round = 0; round < ROUNDS; round += 1) {
|
||||
// 1. preview read (attachImagePath → attachmentPreviewDataUrl)
|
||||
const preview = time(() => readFileDataUrl(path))
|
||||
record('preview_read_dataurl', preview.ms)
|
||||
dataUrl = preview.out
|
||||
|
||||
// 2. submit-time SECOND read of the same file (readImageForRemoteAttach)
|
||||
const attachRead = time(() => readFileDataUrl(path))
|
||||
record('attach_read_dataurl', attachRead.ms)
|
||||
|
||||
// 3. strip the data: prefix
|
||||
const strip = time(() => base64FromDataUrl(attachRead.out))
|
||||
record('base64_from_dataurl', strip.ms)
|
||||
base64 = strip.out
|
||||
|
||||
// 4. JSON-RPC frame for image.attach_bytes
|
||||
const encode = time(() => encodeRpcFrame(base64, 'img.png'))
|
||||
record('rpc_frame_encode', encode.ms)
|
||||
frame = encode.out
|
||||
|
||||
// 5. the optimistic bubble carries the data URL as its attachmentRef
|
||||
bubbleText = dataUrl
|
||||
const extract = time(() => extractEmbeddedImages(bubbleText))
|
||||
record('extract_embedded_images', extract.ms)
|
||||
|
||||
// 6. render-weight walk over the message holding that ref
|
||||
const content = [{ type: 'text', text: 'what is this' }, { attachmentRefs: [dataUrl] }]
|
||||
const weigh = time(() => payloadCharacters(content, MAX_MEASURED_MESSAGE_CHARS))
|
||||
record('render_weight_walk', weigh.ms)
|
||||
|
||||
// 7. draft stash clone with the attachment held
|
||||
const draft = { attachments: [{ id: 'a', kind: 'image', label: 'i', previewUrl: dataUrl, path }], text: 'hi' }
|
||||
const clone = time(() => cloneDraft(draft))
|
||||
record('draft_clone', clone.ms)
|
||||
}
|
||||
|
||||
results.push({
|
||||
kb,
|
||||
fileBytes: readFileSync(path).length,
|
||||
dataUrlChars: dataUrl.length,
|
||||
rpcFrameChars: frame.length,
|
||||
rows
|
||||
})
|
||||
}
|
||||
|
||||
for (const r of results) {
|
||||
console.log(`\n=== ${r.kb} KB image (${r.fileBytes} bytes on disk) ===`)
|
||||
console.log(
|
||||
`data URL: ${r.dataUrlChars.toLocaleString()} chars RPC frame: ${r.rpcFrameChars.toLocaleString()} chars ` +
|
||||
`(${(r.rpcFrameChars / r.fileBytes).toFixed(2)}x the file)`
|
||||
)
|
||||
console.log('')
|
||||
console.log('stage mean p50 p95 max')
|
||||
|
||||
let total = 0
|
||||
|
||||
for (const [stage, samples] of Object.entries(r.rows)) {
|
||||
const s = stat(samples)
|
||||
total += s.mean
|
||||
console.log(
|
||||
`${stage.padEnd(26)} ${s.mean.toFixed(2).padStart(7)} ${s.p50.toFixed(2).padStart(7)} ` +
|
||||
`${s.p95.toFixed(2).padStart(7)} ${s.max.toFixed(2).padStart(7)} ms`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`${'TOTAL (mean)'.padEnd(26)} ${total.toFixed(2).padStart(7)} ms`)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Baseline + regression gate. This is the capability the old one-off scripts
|
||||
// never had: measured numbers are compared against a committed baseline so a
|
||||
// PR that regresses streaming/typing/mount cost fails loudly instead of
|
||||
// silently drifting.
|
||||
//
|
||||
// Every tracked metric is "lower is better" (longtask counts, frame/keystroke
|
||||
// percentiles, mount ms). A metric regresses when it exceeds
|
||||
// `baseline * (1 + tolFrac) + tolAbs`. tolAbs absorbs sub-millisecond jitter on
|
||||
// already-fast metrics so they don't false-positive.
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
const DEFAULT_TOLERANCE = { tolFrac: 0.25, tolAbs: 1 }
|
||||
|
||||
export function loadBaseline(path) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf8'))
|
||||
} catch {
|
||||
return { _meta: {}, scenarios: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a scenario's measured metrics against the baseline.
|
||||
* @returns {{ rows: Array, regressed: boolean }}
|
||||
*/
|
||||
export function compareScenario(name, measured, baseline) {
|
||||
const base = baseline.scenarios?.[name]
|
||||
const tol = { ...DEFAULT_TOLERANCE, ...(base?.tolerance ?? {}) }
|
||||
const rows = []
|
||||
let regressed = false
|
||||
|
||||
for (const [metric, value] of Object.entries(measured)) {
|
||||
if (typeof value !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
const baseValue = base?.metrics?.[metric]
|
||||
|
||||
if (typeof baseValue !== 'number') {
|
||||
rows.push({ metric, measured: value, baseline: null, limit: null, status: 'new' })
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const limit = baseValue * (1 + tol.tolFrac) + tol.tolAbs
|
||||
const over = value > limit
|
||||
regressed = regressed || over
|
||||
|
||||
rows.push({
|
||||
metric,
|
||||
measured: value,
|
||||
baseline: baseValue,
|
||||
limit: Math.round(limit * 100) / 100,
|
||||
deltaPct: baseValue ? Math.round(((value - baseValue) / baseValue) * 1000) / 10 : null,
|
||||
status: over ? 'REGRESSED' : 'ok'
|
||||
})
|
||||
}
|
||||
|
||||
return { rows, regressed }
|
||||
}
|
||||
|
||||
/** Write measured metrics back as the new baseline for the given scenarios. */
|
||||
export function updateBaseline(path, results) {
|
||||
const baseline = loadBaseline(path)
|
||||
baseline.scenarios ??= {}
|
||||
|
||||
for (const { name, metrics } of results) {
|
||||
const numeric = Object.fromEntries(Object.entries(metrics).filter(([, v]) => typeof v === 'number'))
|
||||
const prev = baseline.scenarios[name] ?? {}
|
||||
baseline.scenarios[name] = { ...prev, metrics: numeric }
|
||||
}
|
||||
|
||||
baseline._meta = {
|
||||
...baseline._meta,
|
||||
updated: new Date().toISOString(),
|
||||
platform: `${process.platform}-${process.arch}`,
|
||||
node: process.version
|
||||
}
|
||||
|
||||
writeFileSync(path, `${JSON.stringify(baseline, null, 2)}\n`)
|
||||
}
|
||||
|
||||
export { DEFAULT_TOLERANCE }
|
||||
@@ -0,0 +1,202 @@
|
||||
// The one Chrome DevTools Protocol client for the desktop perf harness.
|
||||
//
|
||||
// Before this, every measure-*/profile-* script shipped its own copy-pasted
|
||||
// `CDP` class (four subtly different implementations), its own `/json` vs
|
||||
// `/json/list` target discovery, and its own Profiler ranking. Scenarios now
|
||||
// import from here so there is a single place to fix a protocol bug.
|
||||
|
||||
const DEFAULT_PORT = 9222
|
||||
|
||||
// Stable DOM hooks the renderer exposes. Centralised so a component refactor
|
||||
// updates one constant instead of a dozen scattered querySelector strings.
|
||||
export const SELECTORS = {
|
||||
composer: '[data-slot="composer-rich-input"]',
|
||||
threadViewport: '[data-slot="aui_thread-viewport"]',
|
||||
threadContent: '[data-slot="aui_thread-content"]',
|
||||
assistantMessage: '[data-slot="aui_assistant-message-root"]',
|
||||
turnPair: '[data-slot="aui_turn-pair"]',
|
||||
profileRail: '[data-slot="profile-rail"]',
|
||||
rowButton: '[data-slot="row-button"]'
|
||||
}
|
||||
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Poll the CDP HTTP endpoint until a page target is available.
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.port] remote-debugging-port (default 9222).
|
||||
* @param {string} [opts.match] substring the target URL must contain (e.g. a dev-server port).
|
||||
* @param {number} [opts.timeoutMs] how long to wait for a target.
|
||||
*/
|
||||
export async function discoverTarget({ port = DEFAULT_PORT, match, timeoutMs = 30000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
|
||||
const pages = list.filter(t => t.type === 'page' && typeof t.webSocketDebuggerUrl === 'string')
|
||||
const target = match
|
||||
? pages.find(t => String(t.url).includes(match))
|
||||
: pages.find(t => String(t.url).startsWith('http')) ?? pages[0]
|
||||
|
||||
if (target) {
|
||||
return target
|
||||
}
|
||||
} catch {
|
||||
// debug port not up yet — keep polling until the deadline.
|
||||
}
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`no CDP page target on :${port}${match ? ` matching "${match}"` : ''} within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
|
||||
export class CDP {
|
||||
constructor(ws) {
|
||||
this.ws = ws
|
||||
this.id = 0
|
||||
this.pending = new Map()
|
||||
this.listeners = new Map()
|
||||
}
|
||||
|
||||
static async open(url) {
|
||||
const ws = new WebSocket(url)
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.addEventListener('open', resolve, { once: true })
|
||||
ws.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
|
||||
const cdp = new CDP(ws)
|
||||
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8'))
|
||||
|
||||
if (m.id != null && cdp.pending.has(m.id)) {
|
||||
const { resolve, reject } = cdp.pending.get(m.id)
|
||||
cdp.pending.delete(m.id)
|
||||
|
||||
if (m.error) {
|
||||
reject(new Error(m.error.message))
|
||||
} else {
|
||||
resolve(m.result)
|
||||
}
|
||||
} else if (m.method) {
|
||||
for (const handler of cdp.listeners.get(m.method) ?? []) {
|
||||
handler(m.params)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
for (const { reject } of cdp.pending.values()) {
|
||||
reject(new Error('CDP socket closed'))
|
||||
}
|
||||
|
||||
cdp.pending.clear()
|
||||
})
|
||||
|
||||
return cdp
|
||||
}
|
||||
|
||||
/** Connect straight to a discovered target. */
|
||||
static async connect(opts) {
|
||||
const target = await discoverTarget(opts)
|
||||
|
||||
return CDP.open(target.webSocketDebuggerUrl)
|
||||
}
|
||||
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
this.ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
on(method, handler) {
|
||||
if (!this.listeners.has(method)) {
|
||||
this.listeners.set(method, [])
|
||||
}
|
||||
|
||||
this.listeners.get(method).push(handler)
|
||||
}
|
||||
|
||||
/** Evaluate an expression in the page and return its value (awaits promises). */
|
||||
async eval(expression) {
|
||||
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
|
||||
|
||||
if (r.exceptionDetails) {
|
||||
throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text || 'eval failed')
|
||||
}
|
||||
|
||||
return r.result.value
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert the renderer has the dev-only `__PERF_DRIVE__` harness attached. */
|
||||
export async function requireDriver(cdp) {
|
||||
const ok = await cdp.eval('!!(window.__PERF_DRIVE__ && window.__PERF_DRIVE__.stream)')
|
||||
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
'__PERF_DRIVE__ not on window. The perf harness needs a DEV renderer ' +
|
||||
'(perf-probe.tsx is excluded from production builds). Launch with `npm run perf:serve`.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Type real key events into the composer, one char at a time, at `cps` chars/sec. */
|
||||
export async function typeIntoComposer(cdp, text, { cps = 15 } = {}) {
|
||||
await cdp.eval(`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
|
||||
if (!el) return false
|
||||
el.focus()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
range.collapse(false)
|
||||
const sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
return true
|
||||
})()`)
|
||||
|
||||
const intervalMs = Math.max(1, Math.round(1000 / cps))
|
||||
|
||||
for (const ch of text) {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: ch, unmodifiedText: ch })
|
||||
await sleep(intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `body()` while a V8 CPU profile is recording. Returns
|
||||
* `{ result, profile }`; the caller decides whether to write the .cpuprofile.
|
||||
*/
|
||||
export async function withCpuProfile(cdp, body, { samplingIntervalUs = 100 } = {}) {
|
||||
await cdp.send('Profiler.enable')
|
||||
await cdp.send('Profiler.setSamplingInterval', { interval: samplingIntervalUs })
|
||||
await cdp.send('Profiler.start')
|
||||
|
||||
let result
|
||||
let stopped
|
||||
|
||||
try {
|
||||
result = await body()
|
||||
} finally {
|
||||
// Always stop so a scenario error can't leave the profiler running.
|
||||
stopped = await cdp.send('Profiler.stop')
|
||||
}
|
||||
|
||||
return { result, profile: stopped.profile }
|
||||
}
|
||||
|
||||
export { sleep }
|
||||
@@ -0,0 +1,423 @@
|
||||
// Connect the harness to a renderer — either an already-running debug instance
|
||||
// (`attach`) or a freshly spawned, fully isolated one (`startIsolatedInstance`).
|
||||
//
|
||||
// The isolated instance is what makes the harness self-contained and unblocks
|
||||
// the measurement that the single-instance lock used to prevent:
|
||||
// · its own --user-data-dir → its own Electron single-instance lock, so it
|
||||
// never collides with (or steals focus from) the user's running `hgui`.
|
||||
// · its own HERMES_HOME → its own backend + sessions, no shared state.
|
||||
// · its own --remote-debugging-port → a private CDP endpoint.
|
||||
// · HERMES_DESKTOP_BOOT_FAKE=1 → deterministic boot overlay.
|
||||
// The synthetic scenarios drive `$messages` directly, so no LLM credits are
|
||||
// spent regardless of the isolated backend.
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { CDP, requireDriver, sleep } from './cdp.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const DESKTOP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
||||
|
||||
async function reachable(url) {
|
||||
try {
|
||||
await fetch(url)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(fn, { timeoutMs, label }) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await fn()) {
|
||||
return
|
||||
}
|
||||
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`)
|
||||
}
|
||||
|
||||
// Seed an isolated HERMES_HOME with just enough config (NOT sessions) so the
|
||||
// spawned instance reaches an empty chat view instead of the onboarding wizard.
|
||||
// A separate HERMES_HOME dir means a separate gateway lock — no collision with
|
||||
// the user's running app, which keeps its own sessions DB and state.
|
||||
function seedConfigFrom(sourceHome, targetHome) {
|
||||
if (!existsSync(sourceHome)) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const name of ['config.yaml', '.env', 'auth.json']) {
|
||||
const from = join(sourceHome, name)
|
||||
|
||||
if (existsSync(from)) {
|
||||
try {
|
||||
copyFileSync(from, join(targetHome, name))
|
||||
} catch {
|
||||
// best-effort — a missing file just means onboarding may appear.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the vite CLI entry via its package.json `bin` (Vite 8's `exports`
|
||||
// blocks importing `vite/bin/vite.js` directly).
|
||||
function resolveViteBin() {
|
||||
const pkgPath = require.resolve('vite/package.json')
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||
const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.vite
|
||||
|
||||
if (!rel) {
|
||||
throw new Error('could not resolve the vite CLI from vite/package.json')
|
||||
}
|
||||
|
||||
return join(dirname(pkgPath), rel)
|
||||
}
|
||||
|
||||
// Poll the perf driver's `connected()` until the gateway socket is open.
|
||||
// Returns false if the probe predates this helper or the timeout elapses.
|
||||
async function waitForConnected(cdp, timeoutMs) {
|
||||
const hasProbe = await cdp.eval('typeof window.__PERF_DRIVE__.connected === "function"')
|
||||
|
||||
if (!hasProbe) {
|
||||
return false
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await cdp.eval('window.__PERF_DRIVE__.connected()')) {
|
||||
return true
|
||||
}
|
||||
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function runProcess(command, args, { env } = {}) {
|
||||
return new Promise((resolveRun, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: DESKTOP_DIR,
|
||||
stdio: 'inherit',
|
||||
env: env ? { ...process.env, ...env } : process.env
|
||||
})
|
||||
child.on('error', reject)
|
||||
child.on('exit', code => (code === 0 ? resolveRun() : reject(new Error(`${command} ${args[0]} exited ${code}`))))
|
||||
})
|
||||
}
|
||||
|
||||
function runNode(scriptRelPath, args = []) {
|
||||
return runProcess(process.execPath, [join(DESKTOP_DIR, scriptRelPath), ...args])
|
||||
}
|
||||
|
||||
// Build a production renderer WITH the perf probe included (VITE_PERF_PROBE=1),
|
||||
// plus the prod electron-main bundle, so the harness can measure a real,
|
||||
// minified React build instead of the ~3x-slower dev build. Slow (a full vite
|
||||
// build); do it once, then run/attach many times.
|
||||
export async function buildProdRenderer() {
|
||||
const viteBin = resolveViteBin()
|
||||
await runProcess(process.execPath, [viteBin, 'build'], { env: { VITE_PERF_PROBE: '1' } })
|
||||
await runNode('scripts/bundle-electron-main.mjs')
|
||||
}
|
||||
|
||||
/** Attach to a renderer already listening on `port` (launched via perf:serve or with --remote-debugging-port). */
|
||||
export async function attach({ port = 9222, match } = {}) {
|
||||
const cdp = await CDP.connect({ port, match })
|
||||
await requireDriver(cdp)
|
||||
|
||||
return { cdp, teardown: () => cdp.close() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an isolated dev instance (vite + electron), wait for the perf driver,
|
||||
* and return `{ cdp, teardown, devUrl, port }`. `teardown` kills both children
|
||||
* and removes any temp dirs it created.
|
||||
*/
|
||||
// Chromium switches that stop frame-production throttling for a window that
|
||||
// isn't foregrounded (the perf window usually sits behind the IDE/terminal).
|
||||
const ANTI_THROTTLE_FLAGS = [
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--disable-features=CalculateNativeWinOcclusion'
|
||||
]
|
||||
|
||||
/**
|
||||
* Spawn an isolated instance and connect the perf driver. Two render modes:
|
||||
* · dev (default): vite dev server + dev electron-main bundle.
|
||||
* · prod (`prod: true`): a production build (call buildProdRenderer first);
|
||||
* electron loads dist/index.html — representative, minified React.
|
||||
* `coldStart: true` skips the gateway-connect wait and settle (for launch-time
|
||||
* measurement) and returns `timings` (spawn→CDP, spawn→driver) plus renderer
|
||||
* boot marks (FCP, time-to-composer).
|
||||
*/
|
||||
export async function startIsolatedInstance({
|
||||
port = 9222,
|
||||
devPort = 5174,
|
||||
prod = false,
|
||||
coldStart = false,
|
||||
hermesHome,
|
||||
userDataDir,
|
||||
seedConfig = true,
|
||||
settleMs = 2500,
|
||||
connectTimeoutMs = 90000
|
||||
} = {}) {
|
||||
const children = []
|
||||
const tempDirs = []
|
||||
|
||||
const mkTemp = prefix => {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
const home = hermesHome ?? mkTemp('hermes-perf-home-')
|
||||
const userData = userDataDir ?? mkTemp('hermes-perf-ud-')
|
||||
const devUrl = prod ? null : `http://127.0.0.1:${devPort}`
|
||||
|
||||
if (seedConfig && !hermesHome) {
|
||||
seedConfigFrom(join(homedir(), '.hermes'), home)
|
||||
}
|
||||
|
||||
const teardown = () => {
|
||||
for (const child of children) {
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of tempDirs) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (prod) {
|
||||
// Renderer + main are expected pre-built (buildProdRenderer). Cheap to
|
||||
// re-bundle main so an isolated run always matches current source.
|
||||
await runNode('scripts/bundle-electron-main.mjs')
|
||||
} else {
|
||||
if (!(await reachable(devUrl))) {
|
||||
const viteBin = resolveViteBin()
|
||||
const vite = spawn(process.execPath, [viteBin, '--host', '127.0.0.1', '--port', String(devPort)], {
|
||||
cwd: DESKTOP_DIR,
|
||||
stdio: ['ignore', 'inherit', 'inherit']
|
||||
})
|
||||
children.push(vite)
|
||||
await waitFor(() => reachable(devUrl), { timeoutMs: 60000, label: `vite dev server on :${devPort}` })
|
||||
}
|
||||
|
||||
await runNode('scripts/bundle-electron-main.mjs', ['--dev'])
|
||||
}
|
||||
|
||||
// Isolated Electron: own --user-data-dir (single-instance lock scope) + own
|
||||
// HERMES_HOME (backend + sessions). No DEV_SERVER env in prod → dist load.
|
||||
const electronBin = require('electron')
|
||||
// NB: do NOT set HERMES_DESKTOP_BOOT_FAKE here — it injects artificial
|
||||
// per-phase sleeps into the boot overlay, which inflates cold-start timing
|
||||
// (and adds pointless startup latency to the steady-state runs). We want the
|
||||
// real boot sequence.
|
||||
const env = {
|
||||
...process.env,
|
||||
HERMES_HOME: home,
|
||||
// The app's dev-CDP resolver (electron/dev-cdp.ts) appends its own
|
||||
// remote-debugging-port switch AFTER argv, so on a non-default --port the
|
||||
// Chromium flag below loses and the instance binds 9222 anyway. The env
|
||||
// override is the supported knob — set it so --port actually wins.
|
||||
HERMES_DESKTOP_CDP_PORT: String(port),
|
||||
XCURSOR_SIZE: '24'
|
||||
}
|
||||
|
||||
if (devUrl) {
|
||||
env.HERMES_DESKTOP_DEV_SERVER = devUrl
|
||||
}
|
||||
|
||||
const spawnAt = Date.now()
|
||||
const electron = spawn(
|
||||
electronBin,
|
||||
['.', `--user-data-dir=${userData}`, `--remote-debugging-port=${port}`, ...ANTI_THROTTLE_FLAGS],
|
||||
{ cwd: DESKTOP_DIR, stdio: ['ignore', 'inherit', 'inherit'], env }
|
||||
)
|
||||
children.push(electron)
|
||||
|
||||
// Wait for the renderer + perf driver. In prod the target URL is file://,
|
||||
// so don't match on the dev port.
|
||||
let cdp = null
|
||||
let cdpAt = 0
|
||||
await waitFor(
|
||||
async () => {
|
||||
try {
|
||||
cdp = await CDP.connect({ port, match: devUrl ? String(devPort) : undefined, timeoutMs: 2000 })
|
||||
cdpAt = cdpAt || Date.now()
|
||||
|
||||
return await cdp.eval('!!(window.__PERF_DRIVE__ && window.__PERF_DRIVE__.stream)')
|
||||
} catch {
|
||||
if (cdp) {
|
||||
cdp.close()
|
||||
cdp = null
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 120000, label: 'isolated renderer + __PERF_DRIVE__' }
|
||||
)
|
||||
const driverAt = Date.now()
|
||||
|
||||
try {
|
||||
await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: true })
|
||||
} catch {
|
||||
// Older CDP / not supported — fall back to the anti-throttle flags.
|
||||
}
|
||||
|
||||
// Renderer-side boot marks (relative to its own navigation start).
|
||||
const bootMarks = await readBootMarks(cdp)
|
||||
const timings = {
|
||||
spawn_to_cdp_ms: cdpAt ? cdpAt - spawnAt : null,
|
||||
spawn_to_driver_ms: driverAt - spawnAt,
|
||||
...bootMarks
|
||||
}
|
||||
|
||||
let connected = true
|
||||
|
||||
if (!coldStart) {
|
||||
// Steady-state scenarios: wait for the gateway to connect (reconnect churn
|
||||
// contaminates frame pacing) and let residual cold-start work drain.
|
||||
connected = await waitForConnected(cdp, connectTimeoutMs)
|
||||
|
||||
if (!connected) {
|
||||
console.warn(
|
||||
`[perf] gateway did not connect within ${connectTimeoutMs}ms — ` +
|
||||
'stream/frame numbers may be inflated by reconnect churn.'
|
||||
)
|
||||
}
|
||||
|
||||
await sleep(settleMs)
|
||||
}
|
||||
|
||||
return {
|
||||
connected,
|
||||
cdp,
|
||||
devUrl,
|
||||
port,
|
||||
prod,
|
||||
timings,
|
||||
teardown: () => {
|
||||
cdp?.close()
|
||||
teardown()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
teardown()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Representative cold-start sampling. A fresh --user-data-dir means a COLD V8
|
||||
// code cache and worst-case bundle recompile every run (~+400ms measured); real
|
||||
// users reuse their profile, so a warm cache is the representative case. We reuse
|
||||
// ONE profile across runs: run 0 warms the cache (discarded), runs 1..N are the
|
||||
// warm samples. Each run steps the port so a just-killed instance can't be
|
||||
// re-attached, and we pause between runs so the single-instance lock releases.
|
||||
export async function coldStartSamples({ runs = 3, port = 9222, devPort = 5174, prod = false, warm = true } = {}) {
|
||||
const pickNumeric = timings => Object.fromEntries(Object.entries(timings).filter(([, v]) => typeof v === 'number'))
|
||||
const samples = []
|
||||
|
||||
if (warm) {
|
||||
// Shared profile across runs: run 0 warms the V8 code cache (discarded),
|
||||
// runs 1..N are the representative warm samples.
|
||||
const home = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-home-'))
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-ud-'))
|
||||
seedConfigFrom(join(homedir(), '.hermes'), home)
|
||||
|
||||
try {
|
||||
for (let i = 0; i <= runs; i++) {
|
||||
const inst = await startIsolatedInstance({
|
||||
port: port + i,
|
||||
devPort: devPort + i,
|
||||
prod,
|
||||
coldStart: true,
|
||||
hermesHome: home,
|
||||
userDataDir,
|
||||
seedConfig: false
|
||||
})
|
||||
|
||||
if (i > 0) {
|
||||
samples.push(pickNumeric(inst.timings))
|
||||
}
|
||||
|
||||
inst.teardown()
|
||||
await sleep(2500) // let the single-instance lock release before reuse
|
||||
}
|
||||
} finally {
|
||||
for (const dir of [home, userDataDir]) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Worst case: a fresh profile per run → cold code cache every launch
|
||||
// (first-launch-after-install). startIsolatedInstance makes+removes its dirs.
|
||||
for (let i = 0; i < runs; i++) {
|
||||
const inst = await startIsolatedInstance({ port: port + i, devPort: devPort + i, prod, coldStart: true })
|
||||
samples.push(pickNumeric(inst.timings))
|
||||
inst.teardown()
|
||||
await sleep(2500)
|
||||
}
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
|
||||
// Read First Contentful Paint + time-to-composer from the renderer, relative to
|
||||
// its navigation start (the process-spawn deltas live in `timings`).
|
||||
async function readBootMarks(cdp) {
|
||||
try {
|
||||
return await cdp.eval(`(() => {
|
||||
const paints = performance.getEntriesByType('paint')
|
||||
const fcp = paints.find(p => p.name === 'first-contentful-paint')
|
||||
const nav = performance.getEntriesByType('navigation')[0]
|
||||
const composer = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
// Largest script resource ≈ the (intentionally single) renderer bundle.
|
||||
// responseEnd → the script's own decode; the eval cost shows up as the gap
|
||||
// between the bundle's responseEnd and domInteractive.
|
||||
const scripts = performance.getEntriesByType('resource').filter(r => r.initiatorType === 'script')
|
||||
const mainScript = scripts.sort((a, b) => (b.encodedBodySize || 0) - (a.encodedBodySize || 0))[0]
|
||||
const round = n => (typeof n === 'number' ? Math.round(n) : null)
|
||||
return {
|
||||
fcp_ms: fcp ? round(fcp.startTime) : null,
|
||||
dom_interactive_ms: nav ? round(nav.domInteractive) : null,
|
||||
dom_content_loaded_ms: nav ? round(nav.domContentLoadedEventEnd) : null,
|
||||
main_script_kb: mainScript ? round((mainScript.encodedBodySize || 0) / 1024) : null,
|
||||
main_script_response_end_ms: mainScript ? round(mainScript.responseEnd) : null,
|
||||
nav_to_read_ms: round(performance.now()),
|
||||
composer_present: !!composer
|
||||
}
|
||||
})()`)
|
||||
} catch {
|
||||
return { fcp_ms: null, dom_interactive_ms: null, composer_present: false }
|
||||
}
|
||||
}
|
||||
|
||||
export { DESKTOP_DIR }
|
||||
@@ -0,0 +1,89 @@
|
||||
// Shared numeric helpers for perf scenarios. Every measure-*/profile-* script
|
||||
// used to carry its own copy of these.
|
||||
|
||||
/** Nearest-rank percentile over an UNSORTED array. p in [0,1]. */
|
||||
export function percentile(values, p) {
|
||||
if (!values.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p))
|
||||
|
||||
return sorted[idx]
|
||||
}
|
||||
|
||||
/** min/p50/p90/p95/p99/max/mean over a sample array (rounded to 2dp). */
|
||||
export function summarize(values) {
|
||||
const round = n => Math.round(n * 100) / 100
|
||||
|
||||
if (!values.length) {
|
||||
return { n: 0, min: 0, p50: 0, p90: 0, p95: 0, p99: 0, max: 0, mean: 0 }
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length
|
||||
|
||||
return {
|
||||
n: values.length,
|
||||
min: round(sorted[0]),
|
||||
p50: round(percentile(sorted, 0.5)),
|
||||
p90: round(percentile(sorted, 0.9)),
|
||||
p95: round(percentile(sorted, 0.95)),
|
||||
p99: round(percentile(sorted, 0.99)),
|
||||
max: round(sorted[sorted.length - 1]),
|
||||
mean: round(mean)
|
||||
}
|
||||
}
|
||||
|
||||
/** Median of a numeric array (used to reduce N repeated runs to one number). */
|
||||
export function median(values) {
|
||||
return percentile(values, 0.5)
|
||||
}
|
||||
|
||||
/** Frame-interval histogram matching the buckets the stream scripts reported. */
|
||||
export function frameHistogram(frames) {
|
||||
const buckets = { '<=16.7': 0, '16.7-33': 0, '33-50': 0, '50-100': 0, '100-200': 0, '>200': 0 }
|
||||
|
||||
for (const f of frames) {
|
||||
if (f <= 16.7) buckets['<=16.7']++
|
||||
else if (f <= 33) buckets['16.7-33']++
|
||||
else if (f <= 50) buckets['33-50']++
|
||||
else if (f <= 100) buckets['50-100']++
|
||||
else if (f <= 200) buckets['100-200']++
|
||||
else buckets['>200']++
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank functions by self-time from a V8 CPU profile (Profiler.stop output).
|
||||
* Returns the top `limit` entries as { ms, name, url, line }.
|
||||
*/
|
||||
export function cpuProfileTopSelf(profile, limit = 30) {
|
||||
const samples = profile.samples || []
|
||||
const timeDeltas = profile.timeDeltas || []
|
||||
const nodes = new Map(profile.nodes.map(n => [n.id, n]))
|
||||
const selfUs = new Map()
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const id = samples[i]
|
||||
selfUs.set(id, (selfUs.get(id) || 0) + (timeDeltas[i] ?? 0))
|
||||
}
|
||||
|
||||
return [...selfUs.entries()]
|
||||
.map(([id, us]) => {
|
||||
const cf = nodes.get(id)?.callFrame || {}
|
||||
|
||||
return {
|
||||
ms: us / 1000,
|
||||
name: cf.functionName || '(anonymous)',
|
||||
url: String(cf.url || '').slice(-70),
|
||||
line: cf.lineNumber
|
||||
}
|
||||
})
|
||||
.filter(x => !/\(root\)|\(idle\)|\(garbage collector\)|\(program\)/.test(x.name))
|
||||
.sort((a, b) => b.ms - a.ms)
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Desktop perf harness entrypoint.
|
||||
//
|
||||
// node scripts/perf/run.mjs [scenarios...] [flags]
|
||||
//
|
||||
// Default (no scenarios): runs the CI suite (stream, keystroke, transcript)
|
||||
// against a renderer on :9222 and diffs the committed baseline.
|
||||
//
|
||||
// Flags:
|
||||
// --spawn launch a fully isolated instance (own user-data-dir +
|
||||
// HERMES_HOME + debug port) instead of attaching
|
||||
// --port <n> CDP port to attach to (default 9222)
|
||||
// --dev-port <n> vite dev-server port to match / spawn (default 5174)
|
||||
// --runs <n> repeat each scenario n times, report the median (default 1)
|
||||
// --cpuprofile [dir] also record a V8 CPU profile per scenario (top-30 self time)
|
||||
// --update-baseline overwrite baseline.json with this run's numbers
|
||||
// --json <path> write the full results JSON here
|
||||
// --tier <ci|backend> run all scenarios of a tier
|
||||
// ...scenario opts e.g. --tokens 600, --turns 400, --real, --a <sid> --b <sid>, --profile <name>
|
||||
//
|
||||
// Examples:
|
||||
// npm run perf # attach to :9222, run CI suite, gate on baseline
|
||||
// npm run perf -- --spawn # isolated instance, no running app needed
|
||||
// npm run perf -- stream --cpuprofile --tokens 800
|
||||
// npm run perf -- --update-baseline
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { withCpuProfile } from './lib/cdp.mjs'
|
||||
import { compareScenario, loadBaseline, updateBaseline } from './lib/baseline.mjs'
|
||||
import { attach, buildProdRenderer, coldStartSamples, startIsolatedInstance } from './lib/launch.mjs'
|
||||
import { cpuProfileTopSelf, median } from './lib/stats.mjs'
|
||||
import { CI_SCENARIOS, SCENARIOS } from './scenarios/index.mjs'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const BASELINE_PATH = join(HERE, 'baseline.json')
|
||||
|
||||
function parseArgs(argv) {
|
||||
const positional = []
|
||||
const flags = {}
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
|
||||
if (arg.startsWith('--')) {
|
||||
const [key, inlineValue] = arg.slice(2).split(/=(.*)/s)
|
||||
const next = argv[i + 1]
|
||||
|
||||
if (inlineValue !== undefined) {
|
||||
flags[key] = inlineValue
|
||||
} else if (next === undefined || next.startsWith('--')) {
|
||||
flags[key] = true
|
||||
} else {
|
||||
flags[key] = next
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
positional.push(arg)
|
||||
}
|
||||
}
|
||||
|
||||
return { positional, flags }
|
||||
}
|
||||
|
||||
function medianMetrics(runs) {
|
||||
const keys = new Set(runs.flatMap(r => Object.keys(r)))
|
||||
const out = {}
|
||||
|
||||
for (const key of keys) {
|
||||
const values = runs.map(r => r[key]).filter(v => typeof v === 'number')
|
||||
out[key] = values.length ? Math.round(median(values) * 10) / 10 : runs[0][key]
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function printMetrics(name, metrics, comparison) {
|
||||
console.log(`\n● ${name}`)
|
||||
const byMetric = new Map((comparison?.rows ?? []).map(r => [r.metric, r]))
|
||||
|
||||
for (const [metric, value] of Object.entries(metrics)) {
|
||||
const row = byMetric.get(metric)
|
||||
|
||||
if (!row || row.baseline === null) {
|
||||
console.log(` ${metric.padEnd(26)} ${String(value).padStart(9)}${row ? ' (new)' : ''}`)
|
||||
} else {
|
||||
const tag = row.status === 'REGRESSED' ? ' ✗ REGRESSED' : ' ✓'
|
||||
const delta = row.deltaPct === null ? '' : ` (${row.deltaPct > 0 ? '+' : ''}${row.deltaPct}%)`
|
||||
console.log(
|
||||
` ${metric.padEnd(26)} ${String(value).padStart(9)} vs ${String(row.baseline).padStart(9)}${delta}${tag}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { positional, flags } = parseArgs(process.argv.slice(2))
|
||||
|
||||
let names = positional
|
||||
if (!names.length) {
|
||||
names = flags.tier ? Object.values(SCENARIOS).filter(s => s.tier === flags.tier).map(s => s.name) : CI_SCENARIOS
|
||||
}
|
||||
|
||||
const unknown = names.filter(n => !SCENARIOS[n])
|
||||
if (unknown.length) {
|
||||
console.error(`unknown scenario(s): ${unknown.join(', ')}\nknown: ${Object.keys(SCENARIOS).join(', ')}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const runs = Number(flags.runs ?? 1)
|
||||
const port = Number(flags.port ?? 9222)
|
||||
const devPort = Number(flags['dev-port'] ?? 5174)
|
||||
const prod = 'prod' in flags
|
||||
const cpuProfile = 'cpuprofile' in flags
|
||||
const cpuProfileDir = typeof flags.cpuprofile === 'string' ? flags.cpuprofile : HERE
|
||||
|
||||
const coldNames = names.filter(n => SCENARIOS[n].tier === 'cold')
|
||||
const liveNames = names.filter(n => SCENARIOS[n].tier !== 'cold')
|
||||
|
||||
// ci + cold metrics are stable enough to gate against the baseline; backend
|
||||
// scenarios vary too much with the live environment, so they're report-only.
|
||||
const GATED = new Set(['ci', 'cold'])
|
||||
const baseline = loadBaseline(BASELINE_PATH)
|
||||
const results = []
|
||||
let regressed = false
|
||||
|
||||
const record = (name, tier, metrics, detail) => {
|
||||
const comparison = GATED.has(tier) ? compareScenario(name, metrics, baseline) : null
|
||||
regressed = regressed || Boolean(comparison?.regressed)
|
||||
results.push({ name, tier, metrics, detail })
|
||||
printMetrics(name, metrics, comparison)
|
||||
}
|
||||
|
||||
if (prod) {
|
||||
if (!flags.spawn) {
|
||||
console.error('--prod requires --spawn (it builds and launches an isolated production renderer)')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
console.log('[perf] building production renderer with the probe (VITE_PERF_PROBE=1)…')
|
||||
await buildProdRenderer()
|
||||
}
|
||||
|
||||
// Cold start measures the launch itself → a fresh spawn per run.
|
||||
if (coldNames.length) {
|
||||
if (!flags.spawn) {
|
||||
console.error('cold-start requires --spawn (it measures a fresh launch)')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// Representative WARM-cache samples (see coldStartSamples). Pass --cold-fresh
|
||||
// to instead measure the worst-case first-launch (cold code cache).
|
||||
const perRun = await coldStartSamples({ runs, port, devPort, prod, warm: !('cold-fresh' in flags) })
|
||||
|
||||
record('cold-start', 'cold', medianMetrics(perRun), { runs, warm: !('cold-fresh' in flags) })
|
||||
}
|
||||
|
||||
// Steady-state scenarios share one persistent connection.
|
||||
if (liveNames.length) {
|
||||
const connection = flags.spawn
|
||||
? await startIsolatedInstance({ port, devPort, prod })
|
||||
: await attach({ port, match: prod ? undefined : String(devPort) })
|
||||
|
||||
const { cdp, teardown } = connection
|
||||
|
||||
try {
|
||||
for (const name of liveNames) {
|
||||
const scenario = SCENARIOS[name]
|
||||
const perRun = []
|
||||
let detail = null
|
||||
|
||||
for (let i = 0; i < runs; i++) {
|
||||
if (cpuProfile && i === 0) {
|
||||
const { result, profile } = await withCpuProfile(cdp, () => scenario.run(cdp, flags))
|
||||
const out = join(cpuProfileDir, `${name}-${Date.now()}.cpuprofile`)
|
||||
writeFileSync(out, JSON.stringify(profile))
|
||||
console.log(`\n[cpuprofile] wrote ${out}`)
|
||||
console.log('[cpuprofile] top self-time (ms):')
|
||||
for (const r of cpuProfileTopSelf(profile, 15)) {
|
||||
console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(38)} ${r.url}:${r.line}`)
|
||||
}
|
||||
perRun.push(result.metrics)
|
||||
detail = result.detail
|
||||
} else {
|
||||
const result = await scenario.run(cdp, flags)
|
||||
perRun.push(result.metrics)
|
||||
detail = result.detail
|
||||
}
|
||||
}
|
||||
|
||||
record(name, scenario.tier, medianMetrics(perRun), detail)
|
||||
}
|
||||
} finally {
|
||||
teardown()
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
writeFileSync(resolve(String(flags.json)), `${JSON.stringify({ timestamp: new Date().toISOString(), results }, null, 2)}\n`)
|
||||
console.log(`\nwrote ${flags.json}`)
|
||||
}
|
||||
|
||||
if (flags['update-baseline']) {
|
||||
updateBaseline(BASELINE_PATH, results.filter(r => GATED.has(r.tier)))
|
||||
console.log(`\nupdated ${BASELINE_PATH}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (regressed) {
|
||||
console.error('\n✗ perf regression vs baseline (see REGRESSED rows above)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('\n✓ no perf regressions')
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('\nperf harness failed:', err.stack ?? err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
// Cold start — launch → renderer → interactive. Unlike the other scenarios this
|
||||
// measures the LAUNCH itself, so it can't run against an already-up instance:
|
||||
// the runner spawns a fresh isolated instance per run (requires --spawn) and
|
||||
// reads the timings/boot-marks the launcher captures. Registered here so it's a
|
||||
// known name with a baseline entry; the actual measurement lives in run.mjs.
|
||||
//
|
||||
// Metrics (lower is better):
|
||||
// spawn_to_cdp_ms process spawn → CDP page target reachable (electron/V8 up)
|
||||
// spawn_to_driver_ms process spawn → renderer mounted + perf driver present
|
||||
// fcp_ms renderer nav start → first contentful paint
|
||||
export default {
|
||||
name: 'cold-start',
|
||||
tier: 'cold',
|
||||
description: 'Launch → first paint → interactive (fresh spawn per run).',
|
||||
run() {
|
||||
throw new Error('cold-start is measured by the runner via fresh spawns; use `--spawn`.')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Time-to-first-token — Enter → first assistant token painted. The latency an
|
||||
// agent app is uniquely judged on, spanning the desktop submit path AND the
|
||||
// backend/agent-loop first-token time. Backend tier: fires a REAL prompt, needs
|
||||
// a live backend (and credits). Report-only.
|
||||
//
|
||||
// node scripts/perf/run.mjs first-token --spawn --prompt "hi"
|
||||
|
||||
import { SELECTORS, sleep, typeIntoComposer } from '../lib/cdp.mjs'
|
||||
import { summarize } from '../lib/stats.mjs'
|
||||
|
||||
export default {
|
||||
name: 'first-token',
|
||||
tier: 'backend',
|
||||
description: 'Enter → first assistant token painted (real backend).',
|
||||
async run(cdp, opts = {}) {
|
||||
const rounds = Number(opts.rounds ?? 3)
|
||||
const prompt = opts.prompt ?? 'reply with a single short sentence'
|
||||
const timeoutMs = Number(opts.timeoutMs ?? 60000)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const firstTokens = []
|
||||
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
const baseText = await cdp.eval(`(() => {
|
||||
const a = document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)})
|
||||
return a.length ? a[a.length - 1].textContent.length : 0
|
||||
})()`)
|
||||
const baseCount = await cdp.eval(`document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length`)
|
||||
|
||||
await typeIntoComposer(cdp, `${prompt} (${i})`, { cps: 60 })
|
||||
const submitAt = Date.now()
|
||||
await cdp.eval(`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
|
||||
el && el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }))
|
||||
})()`)
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let firstTokenMs = null
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(25)
|
||||
const grown = await cdp.eval(`(() => {
|
||||
const a = document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)})
|
||||
if (a.length > ${baseCount}) return true
|
||||
return a.length ? a[a.length - 1].textContent.length > ${baseText} : false
|
||||
})()`)
|
||||
|
||||
if (grown) {
|
||||
firstTokenMs = Date.now() - submitAt
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (firstTokenMs !== null) {
|
||||
firstTokens.push(firstTokenMs)
|
||||
}
|
||||
|
||||
// Let the turn finish before the next round.
|
||||
const turnDeadline = Date.now() + timeoutMs
|
||||
while (Date.now() < turnDeadline) {
|
||||
await sleep(250)
|
||||
const busy = await cdp.eval(`!!document.querySelector('[data-status="running"], [data-busy="true"]')`)
|
||||
|
||||
if (!busy) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
if (!firstTokens.length) {
|
||||
throw new Error('no first token observed — is a backend with credits connected?')
|
||||
}
|
||||
|
||||
const s = summarize(firstTokens)
|
||||
|
||||
return {
|
||||
metrics: { first_token_p50_ms: s.p50, first_token_p95_ms: s.p95 },
|
||||
detail: { rounds, samples: firstTokens, summary: s }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// What does the app cost while a turn is running but NOTHING is arriving?
|
||||
//
|
||||
// The user-visible symptom: with a thread spinning, resizing the sidebar or
|
||||
// typing in the composer feels slow. That is not streaming cost — the stream
|
||||
// is idle. It is the app re-rendering on its own, competing with the
|
||||
// interaction for the main thread.
|
||||
//
|
||||
// This scenario holds N tiles in a busy state, pushes NO tokens, and measures:
|
||||
// - idle_commits_per_s the renderer's self-inflicted commit rate
|
||||
// - drag_fps fps while dragging the sidebar splitter
|
||||
// - type_fps fps while typing in the composer
|
||||
//
|
||||
// A perfectly idle app scores 0 idle commits and pins both interactions at the
|
||||
// display's refresh rate. Every idle commit is main-thread time stolen from an
|
||||
// interaction the user can feel.
|
||||
//
|
||||
// node scripts/perf/run.mjs idle-cost --spawn [--tiles 5] [--seconds 6]
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
|
||||
/** Seed `tiles` busy session tiles. Same publish path as `multitab` /
|
||||
* `render-churn`, but the driver never runs — the turn just stays open. */
|
||||
const setup = (tiles, seedTurns) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
|
||||
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff.' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- Point one.\\n- Point two.\\n' }] }
|
||||
])
|
||||
|
||||
window.__IDLE__ = { ids: [] }
|
||||
for (let n = 1; n <= ${tiles}; n++) {
|
||||
const sid = 'idle-tile-' + n
|
||||
const rid = 'idle-rt-' + n
|
||||
const messages = []
|
||||
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
||||
// An OPEN assistant message: the turn is running, but no tokens arrive.
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: 'Working on it.' }] })
|
||||
|
||||
window.__IDLE__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
})
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
/** Measure the app's self-inflicted commit rate with nothing happening. */
|
||||
const idleCost = seconds => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
rc.start()
|
||||
const t0 = performance.now()
|
||||
await new Promise(r => setTimeout(r, ${seconds} * 1000))
|
||||
const elapsed = (performance.now() - t0) / 1000
|
||||
rc.stop()
|
||||
return JSON.stringify({
|
||||
elapsed,
|
||||
commits: rc.commits(),
|
||||
top: rc.report(12),
|
||||
owners: rc.report(300).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 10)
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
/** Record frame pacing across an interaction driven from the page.
|
||||
*
|
||||
* The gesture body drives itself on requestAnimationFrame, so it IS the frame
|
||||
* clock — timing is taken from those same callbacks rather than a second,
|
||||
* independent rAF ticker. Running two rAF consumers made the observer's
|
||||
* deltas count the driver's frames as well as the app's and reported ~3fps
|
||||
* where the interaction actually ran at ~23fps. `frames` is filled by the
|
||||
* body via `__MARK__`.
|
||||
*
|
||||
* `record` MUST be false for any fps number you intend to believe: the render
|
||||
* counter walks the whole fiber tree on every commit, so recording during a
|
||||
* gesture measures the instrumentation as much as the app. Attribution and
|
||||
* timing therefore run as two separate passes. */
|
||||
const withFrames = (body, record = false) => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
${record ? 'rc.start()' : ''}
|
||||
const frames = []
|
||||
let last = performance.now()
|
||||
// The body calls this once per frame it drives.
|
||||
const __MARK__ = () => {
|
||||
const now = performance.now()
|
||||
frames.push(now - last)
|
||||
last = now
|
||||
}
|
||||
${body}
|
||||
${record ? 'rc.stop()' : ''}
|
||||
const total = frames.reduce((a, b) => a + b, 0)
|
||||
const sorted = [...frames].sort((a, b) => a - b)
|
||||
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
|
||||
return JSON.stringify({
|
||||
fps: total ? (frames.length / total) * 1000 : 0,
|
||||
p95: pct(0.95),
|
||||
worst: sorted.length ? sorted[sorted.length - 1] : 0,
|
||||
slow33: frames.filter(f => f > 33).length,
|
||||
n: frames.length,
|
||||
commits: ${record ? 'rc.commits()' : '0'},
|
||||
top: ${record ? 'rc.report(10)' : '[]'}
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
/** Drag the sidebar splitter — the resize symptom.
|
||||
* Sweeps monotonically (an oscillation nets to zero and can clamp to a no-op),
|
||||
* and reports how far it actually moved so a drag that silently did nothing
|
||||
* shows up as `dragMoved: 0` instead of a confident wrong number. */
|
||||
const DRAG = withFrames(`
|
||||
const handle = document.querySelector('[role="separator"]')
|
||||
window.__DRAG_TARGET__ = handle ? 'separator' : 'none'
|
||||
window.__DRAG_MOVED__ = 0
|
||||
if (handle) {
|
||||
const box = handle.getBoundingClientRect()
|
||||
const y = box.top + box.height / 2
|
||||
const x0 = box.left + box.width / 2
|
||||
let x = x0
|
||||
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
||||
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
||||
// Out 60px then back — a real gesture, with a net displacement at the peak.
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x += 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
}
|
||||
window.__DRAG_MOVED__ = Math.round(x - x0)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
x -= 2
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
||||
} else {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
`)
|
||||
|
||||
/** Type into the composer — the keystroke symptom. */
|
||||
const TYPE = withFrames(`
|
||||
const el = document.querySelector('[contenteditable="true"], textarea')
|
||||
window.__TYPE_TARGET__ = el ? (el.tagName.toLowerCase()) : 'none'
|
||||
if (el) {
|
||||
el.focus()
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const ch = 'performance testing '[i % 20]
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
|
||||
if (el.tagName === 'TEXTAREA') {
|
||||
el.value += ch
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
} else {
|
||||
el.textContent += ch
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
|
||||
}
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
|
||||
// Wait for the frame this keystroke produces, then mark it — same clock
|
||||
// discipline as DRAG, so typing fps is comparable to drag fps.
|
||||
await new Promise(r => requestAnimationFrame(r))
|
||||
__MARK__()
|
||||
await new Promise(r => setTimeout(r, 25))
|
||||
}
|
||||
} else {
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
`)
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__IDLE__) {
|
||||
for (const { sid, rid } of window.__IDLE__.ids) {
|
||||
const states = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__IDLE__ = null
|
||||
}
|
||||
window.__RENDER_COUNTS__.clear()
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places
|
||||
|
||||
export default {
|
||||
name: 'idle-cost',
|
||||
// NOT 'ci': the drag fps this reports (~0.6fps, p95 814ms) contradicts a
|
||||
// direct single-clock probe of the same gesture on the same build (57fps),
|
||||
// and I could not reconcile the two — ruled out sash selection, tile setup,
|
||||
// counter residue, and a 20s soak. Its RENDER attribution and idle commit
|
||||
// rate are trustworthy and are what this scenario is for; the interaction
|
||||
// fps is reported for investigation, not gated on, until that is explained.
|
||||
tier: 'report',
|
||||
description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
const seedTurns = Number(opts.turns ?? 20)
|
||||
const seconds = Number(opts.seconds ?? 6)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns))
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`idle-cost setup failed (${ok}) — needs a dev renderer with src/debug installed.`)
|
||||
}
|
||||
|
||||
for (let n = 1; n <= tiles; n++) {
|
||||
await cdp.eval(reveal(`idle-tile-${n}`))
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
await sleep(1500)
|
||||
|
||||
const idle = JSON.parse(await cdp.eval(idleCost(seconds)))
|
||||
const drag = JSON.parse(await cdp.eval(DRAG))
|
||||
const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"')
|
||||
const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0')
|
||||
const type = JSON.parse(await cdp.eval(TYPE))
|
||||
const typeTarget = await cdp.eval('window.__TYPE_TARGET__ || "unknown"')
|
||||
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
if (dragTarget === 'none') {
|
||||
throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.')
|
||||
}
|
||||
|
||||
if (typeTarget === 'none') {
|
||||
throw new Error('idle-cost: no composer found — the typing pass measured nothing.')
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
// Commits per second with a turn open and nothing arriving. Should be 0.
|
||||
idle_commits_per_s: round(idle.commits / idle.elapsed),
|
||||
idle_renders: idle.top.reduce((a, r) => a + r.renders, 0),
|
||||
// Interaction smoothness while that churn competes for the main thread.
|
||||
// Reported as a deficit from 60fps so "lower is better" matches the
|
||||
// baseline gate's direction.
|
||||
drag_fps_deficit: round(Math.max(0, 60 - drag.fps)),
|
||||
drag_slow_frames: drag.slow33,
|
||||
type_fps_deficit: round(Math.max(0, 60 - type.fps)),
|
||||
type_slow_frames: type.slow33
|
||||
},
|
||||
detail: {
|
||||
tiles,
|
||||
dragTarget,
|
||||
dragMoved,
|
||||
idleSeconds: round(idle.elapsed),
|
||||
dragFps: round(drag.fps),
|
||||
dragP95: round(drag.p95),
|
||||
dragWorst: round(drag.worst),
|
||||
typeFps: round(type.fps),
|
||||
typeP95: round(type.p95),
|
||||
typeWorst: round(type.worst),
|
||||
// Components whose OWN state changed with no prop change: the roots.
|
||||
idleOwners: idle.owners,
|
||||
idleTop: idle.top,
|
||||
dragCommits: drag.commits,
|
||||
dragTop: drag.top,
|
||||
typeCommits: type.commits,
|
||||
typeTop: type.top
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Scenario registry. Add a scenario module here and it's automatically
|
||||
// available to the runner, the default suite (tier 'ci'), and the baseline gate.
|
||||
|
||||
import coldStart from './cold-start.mjs'
|
||||
import firstToken from './first-token.mjs'
|
||||
import idleCost from './idle-cost.mjs'
|
||||
import keystroke from './keystroke.mjs'
|
||||
import multitab from './multitab.mjs'
|
||||
import profileSwitch from './profile-switch.mjs'
|
||||
import renderChurn from './render-churn.mjs'
|
||||
import rightPane from './right-pane.mjs'
|
||||
import sessionLoad from './session-load.mjs'
|
||||
import sessionSwitch from './session-switch.mjs'
|
||||
import stream from './stream.mjs'
|
||||
import streamHistory from './stream-history.mjs'
|
||||
import submit from './submit.mjs'
|
||||
import transcript from './transcript.mjs'
|
||||
|
||||
export const SCENARIOS = {
|
||||
[stream.name]: stream,
|
||||
[streamHistory.name]: streamHistory,
|
||||
[keystroke.name]: keystroke,
|
||||
[transcript.name]: transcript,
|
||||
[multitab.name]: multitab,
|
||||
[renderChurn.name]: renderChurn,
|
||||
[rightPane.name]: rightPane,
|
||||
[idleCost.name]: idleCost,
|
||||
[coldStart.name]: coldStart,
|
||||
[firstToken.name]: firstToken,
|
||||
[submit.name]: submit,
|
||||
[sessionLoad.name]: sessionLoad,
|
||||
[sessionSwitch.name]: sessionSwitch,
|
||||
[profileSwitch.name]: profileSwitch
|
||||
}
|
||||
|
||||
/** Scenarios safe to run with no LLM credits / no live backend — the default suite. */
|
||||
export const CI_SCENARIOS = Object.values(SCENARIOS)
|
||||
.filter(s => s.tier === 'ci')
|
||||
.map(s => s.name)
|
||||
@@ -0,0 +1,99 @@
|
||||
// Composer input latency — keystroke → next paint. Subsumes measure-latency,
|
||||
// profile-typing, and leak-typing. This is the most-felt latency in a chat app
|
||||
// (users type constantly) and nothing measured it against a baseline before.
|
||||
//
|
||||
// Each synthetic char records the time from dispatch to the first rAF after the
|
||||
// composer mutates (a paint proxy). Metrics are p50/p95/p99 and the count of
|
||||
// keystrokes that missed a 16ms frame.
|
||||
|
||||
import { SELECTORS, sleep } from '../lib/cdp.mjs'
|
||||
import { percentile } from '../lib/stats.mjs'
|
||||
|
||||
const INSTALL = `
|
||||
(() => {
|
||||
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
|
||||
if (!el) return false
|
||||
el.focus()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
range.collapse(false)
|
||||
const sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
window.__KEY__ = { samples: [], pending: null }
|
||||
const obs = new MutationObserver(() => {
|
||||
const start = window.__KEY__.pending
|
||||
if (start === null) return
|
||||
window.__KEY__.pending = null
|
||||
requestAnimationFrame(() => window.__KEY__.samples.push(performance.now() - start))
|
||||
})
|
||||
obs.observe(el, { childList: true, subtree: true, characterData: true })
|
||||
window.__KEY__.obs = obs
|
||||
return true
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEAR = `
|
||||
(() => {
|
||||
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
|
||||
if (el) { el.innerHTML = ''; el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) }
|
||||
window.__KEY__ && window.__KEY__.obs && window.__KEY__.obs.disconnect()
|
||||
})()
|
||||
`
|
||||
|
||||
const SENTENCE =
|
||||
'the quick brown fox jumps over the lazy dog while typing into this composer, which should feel instant. '
|
||||
|
||||
export default {
|
||||
name: 'keystroke',
|
||||
tier: 'ci',
|
||||
description: 'Composer keystroke → paint latency while idle.',
|
||||
async run(cdp, opts = {}) {
|
||||
const chars = Number(opts.chars ?? 120)
|
||||
const cps = Number(opts.cps ?? 15)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const installed = await cdp.eval(INSTALL)
|
||||
|
||||
if (!installed) {
|
||||
throw new Error(`composer not found (${SELECTORS.composer}); is a chat view open?`)
|
||||
}
|
||||
|
||||
let text = ''
|
||||
|
||||
while (text.length < chars) {
|
||||
text += SENTENCE
|
||||
}
|
||||
|
||||
text = text.slice(0, chars)
|
||||
const intervalMs = Math.max(1, Math.round(1000 / cps))
|
||||
const start = Date.now()
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
await cdp.eval('window.__KEY__.pending = performance.now()')
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: text[i], unmodifiedText: text[i] })
|
||||
const wait = start + (i + 1) * intervalMs - Date.now()
|
||||
|
||||
if (wait > 0) {
|
||||
await sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(300)
|
||||
const samples = await cdp.eval('window.__KEY__.samples')
|
||||
await cdp.eval(CLEAR)
|
||||
|
||||
const round = n => Math.round(n * 10) / 10
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
keystroke_p50_ms: round(percentile(samples, 0.5)),
|
||||
keystroke_p95_ms: round(percentile(samples, 0.95)),
|
||||
keystroke_p99_ms: round(percentile(samples, 0.99)),
|
||||
keystroke_slow_16: samples.filter(s => s > 16).length
|
||||
},
|
||||
detail: { n: samples.length, typed: text.length }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Multi-tab working sessions: N session tiles stacked as tabs in the main
|
||||
// zone, EVERY tab mounted (keep-alive), all streaming concurrently — the
|
||||
// "5 tabs doing PR review" workload. Measures frame pacing + longtasks while
|
||||
// the whole stack streams, which is where multitab renderers crawl.
|
||||
//
|
||||
// --zones M splits the tiles across M VISIBLE split zones (a 2×2 grid for 4)
|
||||
// instead of one tab stack — the "4 tiles with 4 sessions each" workload,
|
||||
// where M transcripts stream on screen at once and the rest are mounted
|
||||
// keep-alive tabs behind them. --streaming S caps how many sessions are
|
||||
// actually mid-turn (zone leaders first, so S=zones means "every visible
|
||||
// transcript streams, every hidden tab idles"); the rest sit settled.
|
||||
// --sessions N seeds a populated recents list (a lived-in sessions DB).
|
||||
// --turns N sets transcript depth per tile (long sessions), and --tools makes
|
||||
// every transcript an AGENT session: seeded turns carry settled tool rounds,
|
||||
// and the live stream opens/completes tool calls between text chunks.
|
||||
//
|
||||
// Drives the real pipeline synthetically (no backend, no credits): each tick
|
||||
// routes one delta per streaming session through `hook.update` — the same
|
||||
// wiring-cache write (journal + publish + view sync) the gateway's delta
|
||||
// flush performs — via the __HERMES_SESSION_TILES__ hook.
|
||||
//
|
||||
// node scripts/perf/run.mjs multitab --spawn [--tiles 5] [--tokens 240]
|
||||
// node scripts/perf/run.mjs multitab --spawn --tiles 16 --zones 4 --sessions 300
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
import { frameHistogram, percentile } from '../lib/stats.mjs'
|
||||
|
||||
// Same recorder pattern as stream.mjs (generation-guarded rAF + longtasks).
|
||||
const RECORDERS = `
|
||||
(() => {
|
||||
window.__FT_GEN__ = (window.__FT_GEN__ || 0) + 1
|
||||
const ftGen = window.__FT_GEN__
|
||||
window.__FT__ = { times: [], stop: false }
|
||||
let last = performance.now()
|
||||
const tick = () => {
|
||||
if (window.__FT_GEN__ !== ftGen || window.__FT__.stop) return
|
||||
const now = performance.now()
|
||||
window.__FT__.times.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__LT__ = { entries: [], stop: false }
|
||||
try {
|
||||
const po = new PerformanceObserver((list) => {
|
||||
if (window.__LT__.stop) return
|
||||
for (const e of list.getEntries()) window.__LT__.entries.push({ duration: e.duration, startTime: e.startTime })
|
||||
})
|
||||
po.observe({ entryTypes: ['longtask'] })
|
||||
window.__LT__.po = po
|
||||
} catch {}
|
||||
return 'armed'
|
||||
})()
|
||||
`
|
||||
|
||||
const COLLECT = `
|
||||
(() => {
|
||||
window.__FT__.stop = true
|
||||
window.__LT__.stop = true
|
||||
try { window.__LT__.po && window.__LT__.po.disconnect() } catch {}
|
||||
return JSON.stringify({ frames: window.__FT__.times, longtasks: window.__LT__.entries })
|
||||
})()
|
||||
`
|
||||
|
||||
/** Page-side setup: open `tiles` session tiles — one tab stack in the main
|
||||
* zone (zones=1), or spread across `zones` visible splits (a 2×2 grid for 4)
|
||||
* — bind fake runtime ids, and seed each with a realistic transcript.
|
||||
*
|
||||
* States are written through `hook.update` — the REAL gateway write path
|
||||
* (wiring cache + in-flight journal + publish + view sync). Driving
|
||||
* `hook.publish` alone under-models a stream: it skips the journal and the
|
||||
* cache, which is exactly where multi-session cost used to hide. */
|
||||
const setup = (tiles, seedTurns, streamSeed, zones, seedSessions, streaming, dead, tools) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
if (!hook.update) return 'no-update-hook'
|
||||
|
||||
// A settled tool call the way the gateway stores one: streamed args (kept
|
||||
// as argsText too) and a result blob. Real agent transcripts are MOSTLY
|
||||
// these — a long session is hundreds of terminal/read_file/patch rounds.
|
||||
const toolPart = (sid, i, k) => {
|
||||
const args = { command: 'rg -n "handler" src/module-' + i + ' | head -40', background: false }
|
||||
return {
|
||||
type: 'tool-call', toolCallId: sid + '-t' + i + '-' + k, toolName: k % 2 ? 'read_file' : 'terminal',
|
||||
args, argsText: JSON.stringify(args),
|
||||
result: JSON.stringify({ success: true, output: Array.from({ length: 18 },
|
||||
(_, l) => 'src/module-' + i + '.ts:' + (l * 7 + 3) + ': const handler = wrap(ctx, retry)').join('\\n') })
|
||||
}
|
||||
}
|
||||
|
||||
const turn = (sid, i) => {
|
||||
const answer = { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: [
|
||||
'## Finding ' + i, '',
|
||||
'The handler swallows the rejection. Key points for hunk \\\`' + i + '\\\`:', '',
|
||||
'- The catch block drops the original error.',
|
||||
'- Retries are unbounded — see [the loop](https://example.com/loop).', '',
|
||||
'\\\`\\\`\\\`ts',
|
||||
'async function retry' + i + '(fn: () => Promise<void>) {',
|
||||
' for (;;) { try { return await fn() } catch {} }',
|
||||
'}',
|
||||
'\\\`\\\`\\\`', '',
|
||||
'| path | covered |', '|---|---|', '| happy | yes |', '| error | no |', ''
|
||||
].join('\\n') }] }
|
||||
const rows = [
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff in module ' + i + ' handle the error path?' }] }
|
||||
]
|
||||
// Agent work turn (--tools): two tool rounds before the answer, the
|
||||
// shape run_conversation actually produces.
|
||||
if (${tools}) {
|
||||
rows.push({ id: sid + '-w' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: 'Checking module ' + i + '.' }, toolPart(sid, i, 0), toolPart(sid, i, 1)] })
|
||||
}
|
||||
rows.push(answer)
|
||||
return rows
|
||||
}
|
||||
|
||||
const state = (sid, rid, isStreaming) => {
|
||||
const messages = []
|
||||
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
||||
// Streaming tail the driver grows (--code seeds an open fence); a
|
||||
// non-streaming session sits settled — open, mounted, mid-nothing.
|
||||
if (isStreaming) {
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: ${JSON.stringify(streamSeed)} }] })
|
||||
}
|
||||
return {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: isStreaming, awaitingResponse: false,
|
||||
streamId: isStreaming ? sid + '-stream' : null, sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: isStreaming ? Date.now() : null, usage: null
|
||||
}
|
||||
}
|
||||
|
||||
// A populated recents list (--sessions): every store publish re-runs the
|
||||
// busy/attention/draft projections against it, so an empty list hides
|
||||
// that scaling. Restored by CLEANUP.
|
||||
if (${seedSessions} > 0) {
|
||||
window.__MT_SAVED_SESSIONS__ = hook.sessions()
|
||||
const rows = []
|
||||
for (let i = 0; i < ${seedSessions}; i++) {
|
||||
rows.push({
|
||||
id: 'perf-row-' + i, title: 'Seeded session ' + i, ended_at: null,
|
||||
input_tokens: 1200, output_tokens: 800, is_active: false,
|
||||
last_active: Date.now() - i * 60000, message_count: 12,
|
||||
model: 'hermes-4', preview: 'seeded row', cwd: '/tmp/proj-' + (i % 7)
|
||||
})
|
||||
}
|
||||
hook.seedSessions(rows)
|
||||
}
|
||||
|
||||
// Leaked residue (--dead): sessions that ran with no surface referencing
|
||||
// them and then settled — what a day of opening and closing tiles
|
||||
// accumulates. Modeled on the real path (insert while busy, then the
|
||||
// settle publish) so publish-time eviction, where present, engages.
|
||||
// CLEANUP drops whatever survives, for builds without eviction.
|
||||
window.__MT_DEAD__ = []
|
||||
for (let d = 0; d < ${dead}; d++) {
|
||||
const sid = 'perf-dead-' + d
|
||||
const rid = 'perf-dead-rt-' + d
|
||||
window.__MT_DEAD__.push(rid)
|
||||
const settled = state(sid, rid, false)
|
||||
hook.publish(rid, { ...settled, busy: true })
|
||||
hook.publish(rid, settled)
|
||||
}
|
||||
|
||||
// Zone leaders open as visible splits (right of the workspace, then
|
||||
// subdividing that column into a grid); followers stack as tabs into
|
||||
// their zone. zones=1 keeps the classic one-stack workload.
|
||||
const perZone = Math.ceil(${tiles} / ${zones})
|
||||
const leaders = []
|
||||
|
||||
// Streaming slots go to zone LEADERS first (rank orders round-robin across
|
||||
// zones), so --streaming ${'$'}{zones} means "every VISIBLE transcript streams,
|
||||
// every hidden tab idles" — the split the all-vs-visible snapshots diff.
|
||||
window.__MT__ = { ids: [], leaders, streaming: [], timer: null }
|
||||
for (let n = 1; n <= ${tiles}; n++) {
|
||||
const sid = 'perf-tile-' + n
|
||||
const rid = 'perf-rt-' + n
|
||||
window.__MT__.ids.push({ sid, rid })
|
||||
const zone = ${zones} > 1 ? Math.floor((n - 1) / perZone) : 0
|
||||
const posInZone = ${zones} > 1 ? (n - 1) % perZone : n - 1
|
||||
const rank = posInZone * ${zones} + zone
|
||||
const isStreaming = rank < ${streaming}
|
||||
if (isStreaming) window.__MT__.streaming.push(rid)
|
||||
const leader = leaders[zone]
|
||||
if (leader) {
|
||||
hook.open(sid, 'center', 'session-tile:' + leader)
|
||||
} else if (${zones} === 1) {
|
||||
hook.open(sid, 'center')
|
||||
} else {
|
||||
leaders[zone] = sid
|
||||
if (zone === 0) hook.open(sid, 'right')
|
||||
else if (zone === 1) hook.open(sid, 'bottom', 'session-tile:' + leaders[0])
|
||||
else hook.open(sid, 'right', 'session-tile:' + leaders[zone - 2])
|
||||
}
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.update(rid, () => state(sid, rid, isStreaming))
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
// Activate every tab once so keep-alive mounts the full stack (lazy mount:
|
||||
// a never-activated tab stays unmounted, which would understate the cost).
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
/** Page-side driver: grow every tile's streaming tail by `chunk` each
|
||||
* `intervalMs`, through the same write path the gateway flush uses.
|
||||
*
|
||||
* With `tools`, the stream is a working AGENT turn, not a monologue: every
|
||||
* 12th tick opens a live tool call on the streaming message (args, no
|
||||
* result — the running spinner), every 12th+6 completes it with a result
|
||||
* blob, and text keeps flowing between rounds. That exercises the tool-part
|
||||
* update path (find + replace inside the parts array) and the ToolCall
|
||||
* renderer's pending→complete transitions, which text-only streaming never
|
||||
* touches. */
|
||||
const drive = (chunk, intervalMs, totalTokens, tools) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
let pushed = 0
|
||||
const tick = () => {
|
||||
for (const rid of window.__MT__.streaming) {
|
||||
hook.update(rid, prev => {
|
||||
if (!prev.streamId) return prev
|
||||
const messages = prev.messages.map(m => {
|
||||
if (m.id !== prev.streamId) return m
|
||||
const parts = m.parts.slice()
|
||||
if (${tools} && pushed % 12 === 0) {
|
||||
const args = { command: 'npm test -- --run suite-' + pushed, background: false }
|
||||
parts.push({ type: 'tool-call', toolCallId: rid + '-live-' + pushed, toolName: 'terminal',
|
||||
args, argsText: JSON.stringify(args) })
|
||||
} else if (${tools} && pushed % 12 === 6) {
|
||||
for (let p = parts.length - 1; p >= 0; p--) {
|
||||
const part = parts[p]
|
||||
if (part.type === 'tool-call' && part.result === undefined) {
|
||||
parts[p] = { ...part, result: JSON.stringify({ success: true,
|
||||
output: 'suite-' + pushed + ': 214 passed, 0 failed\\n'.repeat(12) }) }
|
||||
break
|
||||
}
|
||||
}
|
||||
parts.push({ type: 'text', text: '' })
|
||||
} else {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last && last.type === 'text') {
|
||||
parts[parts.length - 1] = { type: 'text', text: last.text + ${JSON.stringify(chunk)} }
|
||||
} else {
|
||||
parts.push({ type: 'text', text: ${JSON.stringify(chunk)} })
|
||||
}
|
||||
}
|
||||
return { ...m, parts }
|
||||
})
|
||||
return { ...prev, messages }
|
||||
})
|
||||
}
|
||||
pushed += 1
|
||||
if (pushed < ${totalTokens}) window.__MT__.timer = setTimeout(tick, ${intervalMs})
|
||||
else window.__MT__.done = true
|
||||
}
|
||||
window.__MT__.timer = setTimeout(tick, ${intervalMs})
|
||||
return 'driving'
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (window.__MT_DEAD__) {
|
||||
for (const rid of window.__MT_DEAD__) hook.drop?.(rid)
|
||||
window.__MT_DEAD__ = null
|
||||
}
|
||||
if (window.__MT__) {
|
||||
clearTimeout(window.__MT__.timer)
|
||||
for (const { sid, rid } of window.__MT__.ids) {
|
||||
// Settle through the real path so the in-flight journal entry clears.
|
||||
hook.update(rid, prev => ({ ...prev, busy: false, streamId: null }))
|
||||
hook.close(sid)
|
||||
}
|
||||
window.__MT__ = null
|
||||
}
|
||||
if (window.__MT_SAVED_SESSIONS__) {
|
||||
hook.seedSessions(window.__MT_SAVED_SESSIONS__)
|
||||
window.__MT_SAVED_SESSIONS__ = null
|
||||
}
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
export default {
|
||||
name: 'multitab',
|
||||
tier: 'ci',
|
||||
description: 'N mounted session-tile tabs all streaming: frame pacing + longtasks.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
const zones = Number(opts.zones ?? 1)
|
||||
const seedTurns = Number(opts.turns ?? 20)
|
||||
const seedSessions = Number(opts.sessions ?? 0)
|
||||
const streaming = Math.min(Number(opts.streaming ?? tiles), tiles)
|
||||
const dead = Number(opts.dead ?? 0)
|
||||
// --tools: seeded turns carry settled tool rounds and the live stream
|
||||
// opens/completes tool calls between text — an agent working, not talking.
|
||||
const tools = Boolean(opts.tools)
|
||||
const tokens = Number(opts.tokens ?? 240)
|
||||
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
|
||||
const intervalMs = Number(opts.intervalMs ?? 33)
|
||||
// --code: every tile grows ONE giant fenced code block with no settle
|
||||
// boundaries — what a coding agent streams. The block re-parses and
|
||||
// re-renders fully every flush (block memoization can't settle it), the
|
||||
// documented worst case and the "5 tabs all coding" crawl.
|
||||
const chunk = opts.code
|
||||
? ' const value = await resolve(ctx, { retry: true }) // step\n'
|
||||
: (opts.chunk ?? 'A streamed review sentence with **bold**, `code`, and ordinary prose.\n\n')
|
||||
const streamSeed = opts.code ? '```ts\n' : ''
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns, streamSeed, zones, seedSessions, streaming, dead, tools))
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(`multitab setup failed (${ok}) — dev hooks missing? (needs a dev/probe renderer)`)
|
||||
}
|
||||
|
||||
// Mount every tab (keep-alive mounts on first activation), then settle.
|
||||
// Each reveal is timed to the next paint — with deep transcripts the
|
||||
// first mount is the "why does switching tabs hang" number.
|
||||
const revealMs = []
|
||||
|
||||
for (let n = 1; n <= tiles; n++) {
|
||||
const ms = Number(
|
||||
await cdp.eval(`
|
||||
new Promise(resolve => {
|
||||
const t0 = performance.now()
|
||||
${reveal(`perf-tile-${n}`)}
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now() - t0)))
|
||||
})
|
||||
`)
|
||||
)
|
||||
|
||||
revealMs.push(ms)
|
||||
await sleep(350)
|
||||
}
|
||||
|
||||
// Front each zone's leader so the visible set is one transcript per zone
|
||||
// (the reveal loop above leaves each zone on its LAST tab).
|
||||
if (zones > 1) {
|
||||
const leaders = JSON.parse(await cdp.eval('JSON.stringify(window.__MT__.leaders)'))
|
||||
|
||||
for (const sid of leaders) {
|
||||
await cdp.eval(reveal(sid))
|
||||
await sleep(150)
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(1000)
|
||||
await cdp.eval(RECORDERS)
|
||||
await cdp.eval(drive(chunk, intervalMs, tokens, tools))
|
||||
await sleep(tokens * intervalMs + 1500)
|
||||
|
||||
const data = JSON.parse(await cdp.eval(COLLECT))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
// Drop the first 500ms (recorder install + settle).
|
||||
const frames = []
|
||||
let acc = 0
|
||||
|
||||
for (const f of data.frames) {
|
||||
acc += f
|
||||
|
||||
if (acc >= 500) {
|
||||
frames.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
const ltDurations = data.longtasks.map(e => e.duration)
|
||||
const windowS = frames.reduce((a, b) => a + b, 0) / 1000
|
||||
// The felt numbers: sustained fps over the window, and the fps of the
|
||||
// worst 1-second slice (a 333ms frame IS "3fps" even if the average looks
|
||||
// fine). Worst slice = max summed frame time in any sliding 1s window.
|
||||
const avgFps = windowS ? frames.length / windowS : 0
|
||||
let worstFps = avgFps
|
||||
|
||||
for (let i = 0, j = 0, sum = 0; j < frames.length; j++) {
|
||||
sum += frames[j]
|
||||
|
||||
while (sum > 1000) {
|
||||
sum -= frames[i++]
|
||||
}
|
||||
|
||||
// Only a window that actually spans ~1s counts; short prefixes don't.
|
||||
if (sum >= 900) {
|
||||
worstFps = Math.min(worstFps, ((j - i + 1) / sum) * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
longtasks_n: data.longtasks.length,
|
||||
longtask_max_ms: Math.round((ltDurations.length ? Math.max(...ltDurations) : 0) * 10) / 10,
|
||||
frame_p95_ms: Math.round(percentile(frames, 0.95) * 10) / 10,
|
||||
frame_p99_ms: Math.round(percentile(frames, 0.99) * 10) / 10,
|
||||
slow_frames_33: frames.filter(f => f > 33).length,
|
||||
reveal_max_ms: Math.round(Math.max(...revealMs) * 10) / 10
|
||||
},
|
||||
detail: {
|
||||
tiles,
|
||||
zones,
|
||||
streaming,
|
||||
dead,
|
||||
sessions: seedSessions,
|
||||
tools,
|
||||
turns: seedTurns,
|
||||
code: Boolean(opts.code),
|
||||
windowS: Math.round(windowS * 10) / 10,
|
||||
avgFps: Math.round(avgFps * 10) / 10,
|
||||
worstSecondFps: Math.round(worstFps * 10) / 10,
|
||||
revealMs: revealMs.map(v => Math.round(v)),
|
||||
frameHistogram: frameHistogram(frames)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Profile-switch latency. Subsumes measure-profile-switch. Backend tier: needs
|
||||
// a configured profile in the rail and a live backend. Report-only.
|
||||
//
|
||||
// node scripts/perf/run.mjs profile-switch --profile <name>
|
||||
|
||||
import { SELECTORS, sleep } from '../lib/cdp.mjs'
|
||||
|
||||
export default {
|
||||
name: 'profile-switch',
|
||||
tier: 'backend',
|
||||
description: 'Click a profile in the rail and wait for its sidebar to settle.',
|
||||
requiredOpts: ['profile'],
|
||||
async run(cdp, opts = {}) {
|
||||
const profile = opts.profile
|
||||
const settleTimeoutMs = Number(opts.settleTimeoutMs ?? 60000)
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('profile-switch needs --profile <name>')
|
||||
}
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const t0 = await cdp.eval(`(() => {
|
||||
const rail = document.querySelector(${JSON.stringify(SELECTORS.profileRail)})
|
||||
if (!rail) return null
|
||||
const target = [...rail.querySelectorAll('button, [role="tab"]')].find(b =>
|
||||
((b.getAttribute('aria-label') || '') + ' ' + (b.title || '') + ' ' + (b.textContent || ''))
|
||||
.toLowerCase().includes(${JSON.stringify(String(profile).toLowerCase())}))
|
||||
if (!target) return null
|
||||
target.click()
|
||||
return performance.now()
|
||||
})()`)
|
||||
|
||||
if (t0 === null) {
|
||||
throw new Error(`profile "${profile}" not found in the rail`)
|
||||
}
|
||||
|
||||
const deadline = Date.now() + settleTimeoutMs
|
||||
let settledMs = null
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(100)
|
||||
const s = await cdp.eval(`(() => {
|
||||
const label = [...document.querySelectorAll('div[aria-hidden]')].find(el => /waking up/i.test(el.textContent || ''))
|
||||
const overlayVisible = label ? Number(getComputedStyle(label).opacity) > 0.05 : false
|
||||
return { t: performance.now(), overlayVisible, rows: document.querySelectorAll(${JSON.stringify(SELECTORS.rowButton)}).length }
|
||||
})()`)
|
||||
|
||||
if (!s.overlayVisible && s.rows > 0) {
|
||||
settledMs = s.t - t0
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: { profile_switch_settled_ms: settledMs === null ? -1 : Math.round(settledMs) },
|
||||
detail: { profile, timedOut: settledMs === null }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Render churn during multi-tab streaming: WHAT re-rendered and WHY, and which
|
||||
// store published the update. Frame pacing (see `multitab`) tells you the cost;
|
||||
// this tells you the cause.
|
||||
//
|
||||
// Drives the same synthetic pipeline as `multitab` — publishSessionState per
|
||||
// session per flush via `__HERMES_SESSION_TILES__`, no backend, no credits —
|
||||
// then reads the dev-only counters installed by `src/debug/`:
|
||||
//
|
||||
// window.__RENDER_COUNTS__ — per-component renders, attributed to
|
||||
// props / hook state / parent-only ("wasted")
|
||||
// window.__ATOM_CHURN__ — per-store notifications, listener fan-out, and
|
||||
// notifications whose value was deep-equal to the
|
||||
// previous one ("wasted")
|
||||
//
|
||||
// The headline metric is `sidebar_renders`: how many times the sidebar tree
|
||||
// re-rendered while agents were typing in other tabs. It should be 0.
|
||||
//
|
||||
// node scripts/perf/run.mjs render-churn --spawn [--tiles 5] [--tokens 240]
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
|
||||
/** Components that make up the sidebar tree. A render of any of these while
|
||||
* a background tab streams is work the user cannot see. */
|
||||
const SIDEBAR_COMPONENTS = [
|
||||
'ChatSidebar',
|
||||
'SidebarSurface',
|
||||
'SessionRow',
|
||||
'SessionsSection',
|
||||
'CronJobsSection',
|
||||
'ProfileSwitcher',
|
||||
'VirtualSessionList',
|
||||
'WorkspaceGroup',
|
||||
'OverviewRow',
|
||||
'SessionStatusDot'
|
||||
]
|
||||
|
||||
/** Page-side setup: open `tiles` session tiles, seed each with a transcript.
|
||||
* Mirrors `multitab.mjs` so the two scenarios measure the same workload. */
|
||||
const setup = (tiles, seedTurns) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
if (!hook) return 'no-hook'
|
||||
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
|
||||
if (!window.__ATOM_CHURN__) return 'no-atom-churn'
|
||||
|
||||
const turn = (sid, i) => ([
|
||||
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
||||
parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff handle the error path?' }] },
|
||||
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
||||
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- The catch block drops the error.\\n- Retries are unbounded.\\n' }] }
|
||||
])
|
||||
|
||||
const state = (sid) => {
|
||||
const messages = []
|
||||
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
|
||||
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
||||
parts: [{ type: 'text', text: '' }] })
|
||||
return {
|
||||
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
||||
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
||||
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
||||
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
||||
needsInput: false, turnStartedAt: Date.now(), usage: null
|
||||
}
|
||||
}
|
||||
|
||||
window.__RC__ = { ids: [], timer: null }
|
||||
for (let n = 1; n <= ${tiles}; n++) {
|
||||
const sid = 'churn-tile-' + n
|
||||
const rid = 'churn-rt-' + n
|
||||
window.__RC__.ids.push({ sid, rid })
|
||||
hook.open(sid, 'center')
|
||||
hook.patch(sid, { runtimeId: rid })
|
||||
hook.publish(rid, state(sid))
|
||||
}
|
||||
return 'ok'
|
||||
})()
|
||||
`
|
||||
|
||||
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
||||
|
||||
/** Grow every tile's streaming tail by `chunk` each `intervalMs`, through the
|
||||
* same publish path the gateway's delta flush uses. */
|
||||
const drive = (chunk, intervalMs, totalTokens) => `
|
||||
(() => {
|
||||
const hook = window.__HERMES_SESSION_TILES__
|
||||
let pushed = 0
|
||||
const tick = () => {
|
||||
const states = hook.states()
|
||||
for (const { rid } of window.__RC__.ids) {
|
||||
const prev = states[rid]
|
||||
if (!prev) continue
|
||||
const messages = prev.messages.map(m => {
|
||||
if (m.id !== prev.streamId) return m
|
||||
const head = m.parts.slice(0, -1)
|
||||
const last = m.parts[m.parts.length - 1]
|
||||
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
|
||||
})
|
||||
hook.publish(rid, { ...prev, messages })
|
||||
}
|
||||
pushed += 1
|
||||
if (pushed < ${totalTokens}) window.__RC__.timer = setTimeout(tick, ${intervalMs})
|
||||
else window.__RC__.done = true
|
||||
}
|
||||
window.__RC__.timer = setTimeout(tick, ${intervalMs})
|
||||
return 'driving'
|
||||
})()
|
||||
`
|
||||
|
||||
/** Wait until the renderer stops committing on its own, so the recording window
|
||||
* captures STREAMING cost and not whatever boot/hydration work happened to
|
||||
* still be in flight. Returns `quiet:N` once commits hold still for `quietMs`.
|
||||
*
|
||||
* If it returns `timeout:...` the app never went idle at all — with tiles
|
||||
* marked busy and NO driver running, that means something is ticking on its
|
||||
* own. The report of what rendered during the wait is attached so the culprit
|
||||
* is named rather than guessed at. */
|
||||
const quiesce = (quietMs, timeoutMs) => `
|
||||
(async () => {
|
||||
const rc = window.__RENDER_COUNTS__
|
||||
rc.start()
|
||||
const deadline = Date.now() + ${timeoutMs}
|
||||
const startedAt = Date.now()
|
||||
let last = -1
|
||||
let stableSince = Date.now()
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
const n = rc.commits()
|
||||
if (n !== last) { last = n; stableSince = Date.now(); continue }
|
||||
if (Date.now() - stableSince >= ${quietMs}) { rc.stop(); return 'quiet:' + n }
|
||||
}
|
||||
const idle = {
|
||||
commits: last,
|
||||
seconds: (Date.now() - startedAt) / 1000,
|
||||
top: rc.report(8),
|
||||
// Who OWNS the update? The component whose own hook state changed with
|
||||
// no changed props is the root of a churn cascade; everything under it
|
||||
// is collateral. Naming it is the difference between fixing the cause
|
||||
// and memoizing a symptom.
|
||||
owners: rc.report(200).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 8)
|
||||
}
|
||||
rc.stop()
|
||||
return 'timeout:' + JSON.stringify(idle)
|
||||
})()
|
||||
`
|
||||
|
||||
const START = `
|
||||
(() => {
|
||||
window.__RENDER_COUNTS__.start()
|
||||
window.__ATOM_CHURN__.start()
|
||||
return 'recording'
|
||||
})()
|
||||
`
|
||||
|
||||
const COLLECT = `
|
||||
(() => {
|
||||
window.__RENDER_COUNTS__.stop()
|
||||
window.__ATOM_CHURN__.stop()
|
||||
return JSON.stringify({
|
||||
commits: window.__RENDER_COUNTS__.commits(),
|
||||
renders: window.__RENDER_COUNTS__.report(200),
|
||||
atoms: window.__ATOM_CHURN__.report(200)
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
const CLEANUP = `
|
||||
(() => {
|
||||
if (window.__RC__) {
|
||||
clearTimeout(window.__RC__.timer)
|
||||
for (const { sid, rid } of window.__RC__.ids) {
|
||||
const states = window.__HERMES_SESSION_TILES__.states()
|
||||
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
|
||||
window.__HERMES_SESSION_TILES__.close(sid)
|
||||
}
|
||||
window.__RC__ = null
|
||||
}
|
||||
window.__RENDER_COUNTS__.clear()
|
||||
window.__ATOM_CHURN__.clear()
|
||||
return 'cleaned'
|
||||
})()
|
||||
`
|
||||
|
||||
export default {
|
||||
name: 'render-churn',
|
||||
tier: 'ci',
|
||||
description: 'N streaming tabs: per-component render attribution + store churn.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tiles = Number(opts.tiles ?? 5)
|
||||
const seedTurns = Number(opts.turns ?? 20)
|
||||
const tokens = Number(opts.tokens ?? 240)
|
||||
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
|
||||
const intervalMs = Number(opts.intervalMs ?? 33)
|
||||
const chunk = opts.chunk ?? 'A streamed review sentence with **bold** and `code`.\n\n'
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ok = await cdp.eval(setup(tiles, seedTurns))
|
||||
|
||||
if (ok !== 'ok') {
|
||||
throw new Error(
|
||||
`render-churn setup failed (${ok}) — needs a dev renderer with src/debug installed ` +
|
||||
'(the counters are aliased out of production builds unless VITE_PERF_PROBE=1).'
|
||||
)
|
||||
}
|
||||
|
||||
// Mount every tab (keep-alive mounts on first activation), then settle.
|
||||
for (let n = 1; n <= tiles; n++) {
|
||||
await cdp.eval(reveal(`churn-tile-${n}`))
|
||||
await sleep(350)
|
||||
}
|
||||
|
||||
// Let the app go quiet before recording, so boot/hydration commits that
|
||||
// happen to still be in flight don't land in the streaming window. This is
|
||||
// what makes runs comparable — a fixed sleep let 2-4x of hydration churn
|
||||
// leak in depending on machine load.
|
||||
const settle = await cdp.eval(quiesce(600, 15000))
|
||||
await cdp.eval(START)
|
||||
await cdp.eval(drive(chunk, intervalMs, tokens))
|
||||
await sleep(tokens * intervalMs + 1500)
|
||||
|
||||
const data = JSON.parse(await cdp.eval(COLLECT))
|
||||
await cdp.eval(CLEANUP)
|
||||
|
||||
const byName = new Map(data.renders.map(r => [r.name, r]))
|
||||
const sidebarRows = SIDEBAR_COMPONENTS.map(n => byName.get(n)).filter(Boolean)
|
||||
const sidebarRenders = sidebarRows.reduce((a, r) => a + r.renders, 0)
|
||||
const sidebarWasted = sidebarRows.reduce((a, r) => a + r.wasted, 0)
|
||||
const totalRenders = data.renders.reduce((a, r) => a + r.renders, 0)
|
||||
const totalWasted = data.renders.reduce((a, r) => a + r.wasted, 0)
|
||||
const atomWasted = data.atoms.reduce((a, r) => a + r.wasted, 0)
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
// The hypothesis, as a number: sidebar renders while background tabs
|
||||
// stream. Should be 0.
|
||||
sidebar_renders: sidebarRenders,
|
||||
sidebar_wasted: sidebarWasted,
|
||||
// Renders with no changed props and no changed hook state — pure
|
||||
// parent-driven work, across the whole tree.
|
||||
wasted_renders: totalWasted,
|
||||
total_renders: totalRenders,
|
||||
commits: data.commits,
|
||||
// Store notifications that published a value equal to the last one.
|
||||
wasted_notifies: atomWasted
|
||||
},
|
||||
detail: {
|
||||
tiles,
|
||||
tokens,
|
||||
// 'quiet:N' = the app went idle before recording (comparable run).
|
||||
// 'timeout:N' = it never did, so boot churn is mixed into the numbers.
|
||||
settle,
|
||||
sidebar: sidebarRows,
|
||||
topRenders: data.renders.slice(0, 15),
|
||||
topAtoms: data.atoms.slice(0, 15)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// File-tree + terminal workspace stress. This is the regression scene for the
|
||||
// desktop symptom where opening the project tree/terminal made the whole page
|
||||
// hitch while chat and PTY output continued.
|
||||
//
|
||||
// It mounts a real project tree, one PTY plus multiple persistent xterm tabs,
|
||||
// streams chat and terminal output together, mutates Git decoration state, and
|
||||
// drags the terminal split. The debug probe records the specific work we care
|
||||
// about rather than inferring it from CPU alone:
|
||||
// - fixed-overlay measurements
|
||||
// - active/hidden xterm fits
|
||||
// - ProjectTree + per-path row renders
|
||||
// - frame pacing / slow frames
|
||||
//
|
||||
// npm run perf -- right-pane --spawn --prod --runs 3
|
||||
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
import { frameHistogram, percentile } from '../lib/stats.mjs'
|
||||
|
||||
const DEFAULT_CWD = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
|
||||
const RECORDERS = `
|
||||
(() => {
|
||||
window.__RP_FRAME_GEN__ = (window.__RP_FRAME_GEN__ || 0) + 1
|
||||
const generation = window.__RP_FRAME_GEN__
|
||||
window.__RP_FRAMES__ = { times: [], stop: false }
|
||||
let last = performance.now()
|
||||
const tick = () => {
|
||||
if (window.__RP_FRAME_GEN__ !== generation || window.__RP_FRAMES__.stop) return
|
||||
const now = performance.now()
|
||||
window.__RP_FRAMES__.times.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__RP_LONG__ = { entries: [], stop: false }
|
||||
try {
|
||||
const observer = new PerformanceObserver(list => {
|
||||
if (window.__RP_LONG__.stop) return
|
||||
for (const entry of list.getEntries()) {
|
||||
window.__RP_LONG__.entries.push({ duration: entry.duration, startTime: entry.startTime })
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['longtask'] })
|
||||
window.__RP_LONG__.observer = observer
|
||||
} catch {}
|
||||
return 'armed'
|
||||
})()
|
||||
`
|
||||
|
||||
const COLLECT_RECORDERS = `
|
||||
(() => {
|
||||
window.__RP_FRAMES__.stop = true
|
||||
window.__RP_LONG__.stop = true
|
||||
try { window.__RP_LONG__.observer && window.__RP_LONG__.observer.disconnect() } catch {}
|
||||
return JSON.stringify({ frames: window.__RP_FRAMES__.times, longtasks: window.__RP_LONG__.entries })
|
||||
})()
|
||||
`
|
||||
|
||||
const START_COUNTERS = `window.__RIGHT_PANE_PERF__.start(); 'recording'`
|
||||
const SNAPSHOT_COUNTERS = `
|
||||
(() => {
|
||||
window.__RIGHT_PANE_PERF__.stop()
|
||||
return JSON.stringify(window.__RIGHT_PANE_PERF__.snapshot())
|
||||
})()
|
||||
`
|
||||
|
||||
const DRAG_TERMINAL_SPLIT = `
|
||||
(async () => {
|
||||
const slot = document.querySelector('[data-terminal-slot]')
|
||||
const overlay = document.querySelector('[data-persistent-terminal]')
|
||||
if (!slot || !overlay) return JSON.stringify({ target: 'none', drift: -1, moved: 0 })
|
||||
|
||||
const slotBox = slot.getBoundingClientRect()
|
||||
const candidates = [...document.querySelectorAll('[role="separator"]')]
|
||||
.map(element => ({ element, box: element.getBoundingClientRect() }))
|
||||
.filter(item => item.box.width > item.box.height * 3)
|
||||
.sort((a, b) =>
|
||||
Math.abs((a.box.top + a.box.bottom) / 2 - slotBox.top) -
|
||||
Math.abs((b.box.top + b.box.bottom) / 2 - slotBox.top)
|
||||
)
|
||||
const target = candidates[0]
|
||||
if (!target) return JSON.stringify({ target: 'none', drift: -1, moved: 0 })
|
||||
|
||||
const x = target.box.left + target.box.width / 2
|
||||
const y0 = target.box.top + target.box.height / 2
|
||||
let y = y0
|
||||
const pointer = {
|
||||
bubbles: true, cancelable: true, pointerId: 91, pointerType: 'mouse',
|
||||
isPrimary: true, button: 0, buttons: 1
|
||||
}
|
||||
target.element.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, clientX: x, clientY: y }))
|
||||
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
y -= 1
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...pointer, clientX: x, clientY: y }))
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
}
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
y += 1
|
||||
window.dispatchEvent(new PointerEvent('pointermove', { ...pointer, clientX: x, clientY: y }))
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
}
|
||||
window.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0, clientX: x, clientY: y }))
|
||||
// Track-size transitions continue briefly after pointerup. Wait through
|
||||
// that animation, then give the overlay its normal two-frame calibration.
|
||||
await new Promise(resolve => setTimeout(resolve, 350))
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
|
||||
const a = slot.getBoundingClientRect()
|
||||
const b = overlay.getBoundingClientRect()
|
||||
const drift = Math.max(
|
||||
Math.abs(a.top - b.top),
|
||||
Math.abs(a.left - b.left),
|
||||
Math.abs(a.width - b.width),
|
||||
Math.abs(a.height - b.height)
|
||||
)
|
||||
return JSON.stringify({ target: 'horizontal-separator', drift, moved: 24 })
|
||||
})()
|
||||
`
|
||||
|
||||
async function waitFor(cdp, expression, label, timeoutMs = 20000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await cdp.eval(expression)) {
|
||||
return
|
||||
}
|
||||
|
||||
await sleep(100)
|
||||
}
|
||||
|
||||
throw new Error(`right-pane timed out waiting for ${label}`)
|
||||
}
|
||||
|
||||
const trimWarmup = (frames, warmupMs = 300) => {
|
||||
const kept = []
|
||||
let elapsed = 0
|
||||
|
||||
for (const frame of frames) {
|
||||
elapsed += frame
|
||||
|
||||
if (elapsed >= warmupMs) {
|
||||
kept.push(frame)
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'right-pane',
|
||||
tier: 'report',
|
||||
description: 'Project tree + persistent terminal tabs under chat/terminal output and split dragging.',
|
||||
async run(cdp, opts = {}) {
|
||||
const cwd = resolve(String(opts.cwd ?? DEFAULT_CWD))
|
||||
const terminalCount = Math.max(2, Number(opts.terminals ?? 3))
|
||||
const tokens = Number(opts.tokens ?? 90)
|
||||
const outputChunks = Number(opts.outputChunks ?? 160)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const ready = await cdp.eval(
|
||||
`!!(window.__PERF_DRIVE__?.rightPaneSetup && window.__RIGHT_PANE_PERF__ && window.__HERMES_LAYOUT_TREE__)`
|
||||
)
|
||||
|
||||
if (!ready) {
|
||||
throw new Error('right-pane needs a dev renderer or a production build with VITE_PERF_PROBE=1.')
|
||||
}
|
||||
|
||||
let setup
|
||||
|
||||
try {
|
||||
setup = await cdp.eval(
|
||||
`window.__PERF_DRIVE__.rightPaneSetup(${JSON.stringify({ cwd, terminals: terminalCount })})`
|
||||
)
|
||||
await cdp.eval(`window.__HERMES_LAYOUT_TREE__.reveal('files'); window.__HERMES_LAYOUT_TREE__.reveal('terminal')`)
|
||||
await waitFor(cdp, `!!document.querySelector('[data-project-tree]')`, 'project tree')
|
||||
await waitFor(
|
||||
cdp,
|
||||
`!!document.querySelector('[data-terminal-slot]') && !!document.querySelector('[data-persistent-terminal]')`,
|
||||
'persistent terminal'
|
||||
)
|
||||
await waitFor(
|
||||
cdp,
|
||||
`document.querySelectorAll('[data-terminal] .xterm').length >= ${terminalCount}`,
|
||||
`${terminalCount} mounted xterms`,
|
||||
30000
|
||||
)
|
||||
await sleep(1200)
|
||||
|
||||
// Activate every keep-alive tab once, then return to the output tab.
|
||||
// Each activation should restore exactly one fit; inactive tabs must stay
|
||||
// at zero even while another tab resizes or writes output.
|
||||
await cdp.eval(START_COUNTERS)
|
||||
|
||||
for (const id of setup.terminalIds) {
|
||||
await cdp.eval(`window.__PERF_DRIVE__.rightPaneSelect(${JSON.stringify(id)})`)
|
||||
await sleep(180)
|
||||
}
|
||||
|
||||
const activation = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
|
||||
await cdp.eval(RECORDERS)
|
||||
|
||||
// Chat DOM churn is deliberately measured in its own counter window:
|
||||
// terminal positioning should receive no wakeups from transcript changes.
|
||||
await cdp.eval(START_COUNTERS)
|
||||
await cdp.eval(
|
||||
`window.__PERF_DRIVE__.stream({
|
||||
chunk: 'Right pane streaming sentence with **bold** and \`code\`.\\n\\n',
|
||||
intervalMs: 16,
|
||||
totalTokens: ${tokens},
|
||||
flushMinMs: 33
|
||||
})`
|
||||
)
|
||||
await cdp.eval(`
|
||||
(() => {
|
||||
let n = 0
|
||||
window.__RP_OUTPUT_TIMER__ = setInterval(() => {
|
||||
window.__PERF_DRIVE__.rightPaneWrite(
|
||||
${JSON.stringify(setup.procId)},
|
||||
'terminal output line ' + n + ' ........................................\\r\\n'
|
||||
)
|
||||
n += 1
|
||||
if (n >= ${outputChunks}) clearInterval(window.__RP_OUTPUT_TIMER__)
|
||||
}, 16)
|
||||
return 'writing'
|
||||
})()
|
||||
`)
|
||||
await sleep(Math.max(tokens, outputChunks) * 16 + 900)
|
||||
const stream = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
|
||||
// An unrelated Git status publication should render neither the tree root
|
||||
// nor any visible row. A status for one visible path should touch only it.
|
||||
await cdp.eval(START_COUNTERS)
|
||||
await cdp.eval(`window.__PERF_DRIVE__.rightPaneGit('__right_pane_unrelated__.txt', 'modified')`)
|
||||
await sleep(250)
|
||||
const unrelatedGit = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
|
||||
const visiblePath = await cdp.eval(
|
||||
`document.querySelector('[data-project-tree] [title]')?.getAttribute('title') || ''`
|
||||
)
|
||||
let affectedGit = { counts: { 'project-tree-render': 0, 'project-tree-row-render': 0 }, rows: {} }
|
||||
|
||||
if (visiblePath) {
|
||||
const relative = String(visiblePath).startsWith(`${cwd}/`)
|
||||
? String(visiblePath).slice(cwd.length + 1)
|
||||
: String(visiblePath)
|
||||
await cdp.eval(START_COUNTERS)
|
||||
await cdp.eval(`window.__PERF_DRIVE__.rightPaneGit(${JSON.stringify(relative)}, 'modified')`)
|
||||
await sleep(250)
|
||||
affectedGit = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
}
|
||||
|
||||
await cdp.eval(START_COUNTERS)
|
||||
const drag = JSON.parse(await cdp.eval(DRAG_TERMINAL_SPLIT))
|
||||
const dragCounters = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
|
||||
const recorded = JSON.parse(await cdp.eval(COLLECT_RECORDERS))
|
||||
const frames = trimWarmup(recorded.frames)
|
||||
const longtasks = recorded.longtasks.map(entry => entry.duration)
|
||||
const streamCounts = stream.counts
|
||||
const activationCounts = activation.counts
|
||||
const unrelatedCounts = unrelatedGit.counts
|
||||
const affectedRows = Object.values(affectedGit.rows).reduce((sum, count) => sum + count, 0)
|
||||
const affectedPaths = Object.keys(affectedGit.rows).length
|
||||
|
||||
if (drag.target === 'none') {
|
||||
throw new Error('right-pane found no horizontal terminal split separator.')
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
chat_terminal_measures: streamCounts['terminal-measure'],
|
||||
hidden_terminal_fits: activationCounts['terminal-fit-hidden'] + streamCounts['terminal-fit-hidden'],
|
||||
activation_fit_mismatch: Math.abs(activationCounts['terminal-fit-active'] - setup.terminalIds.length),
|
||||
unrelated_tree_renders: unrelatedCounts['project-tree-render'],
|
||||
unrelated_row_renders: unrelatedCounts['project-tree-row-render'],
|
||||
affected_tree_renders: affectedGit.counts['project-tree-render'],
|
||||
affected_row_path_excess: Math.max(0, affectedPaths - 1),
|
||||
terminal_drift_px: Math.round(drag.drift * 10) / 10,
|
||||
frame_p95_ms: Math.round(percentile(frames, 0.95) * 10) / 10,
|
||||
frame_p99_ms: Math.round(percentile(frames, 0.99) * 10) / 10,
|
||||
slow_frames_33: frames.filter(frame => frame > 33).length,
|
||||
longtask_max_ms: Math.round((longtasks.length ? Math.max(...longtasks) : 0) * 10) / 10
|
||||
},
|
||||
detail: {
|
||||
cwd,
|
||||
terminals: setup.terminalIds.length,
|
||||
activation,
|
||||
stream,
|
||||
unrelatedGit,
|
||||
affectedGit,
|
||||
affectedRows,
|
||||
drag,
|
||||
dragCounters,
|
||||
frameHistogram: frameHistogram(frames),
|
||||
frames: frames.length
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await cdp.eval(`
|
||||
(() => {
|
||||
clearInterval(window.__RP_OUTPUT_TIMER__)
|
||||
window.__RIGHT_PANE_PERF__?.stop()
|
||||
window.__PERF_DRIVE__?.reset()
|
||||
return 'cleaned'
|
||||
})()
|
||||
`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Visual stability of a session LOAD. Sibling to `submit` (which measures the
|
||||
// jump on Enter); this one measures the jump on opening a session — the
|
||||
// prepend/settle path, not the append path.
|
||||
//
|
||||
// Clicks sidebar rows and tracks the bottom-most turn's on-screen top every
|
||||
// frame. A clean load never moves it after first paint; a janky one strands it
|
||||
// thousands of px away while the render-budget backfill and stick-to-bottom
|
||||
// argue. Backend tier: needs real stored sessions in the sidebar.
|
||||
//
|
||||
// node scripts/perf/run.mjs session-load --rows 2,5,8 --rounds 2
|
||||
|
||||
import { SELECTORS, sleep } from '../lib/cdp.mjs'
|
||||
import { summarize } from '../lib/stats.mjs'
|
||||
|
||||
// Below this a shift is sub-perceptual (sub-pixel rounding, a settling caret).
|
||||
const SHIFT_PX = 4
|
||||
|
||||
const ARM = `
|
||||
(() => {
|
||||
const samples = []
|
||||
const t0 = performance.now()
|
||||
let running = true
|
||||
|
||||
const tick = () => {
|
||||
if (!running) return
|
||||
const v = document.querySelector(${JSON.stringify(SELECTORS.threadViewport)})
|
||||
|
||||
if (v) {
|
||||
const turns = v.querySelectorAll(
|
||||
${JSON.stringify(SELECTORS.turnPair)} + ',' + ${JSON.stringify(SELECTORS.assistantMessage)}
|
||||
)
|
||||
const last = turns[turns.length - 1]
|
||||
const rect = last && last.getBoundingClientRect()
|
||||
samples.push({
|
||||
st: Math.round(v.scrollTop),
|
||||
sh: v.scrollHeight,
|
||||
ch: v.clientHeight,
|
||||
bottomTop: rect ? Math.round(rect.top - v.getBoundingClientRect().top) : null,
|
||||
turns: turns.length,
|
||||
t: Math.round(performance.now() - t0)
|
||||
})
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick)
|
||||
window.__SL = { samples, stop() { running = false } }
|
||||
})()
|
||||
`
|
||||
|
||||
const CLICK = index => `
|
||||
(() => {
|
||||
const rows = [...document.querySelectorAll(${JSON.stringify(SELECTORS.rowButton)})].filter(el => el.offsetParent)
|
||||
const row = rows[${index}]
|
||||
if (!row) return null
|
||||
row.click()
|
||||
return (row.textContent ?? '').slice(0, 34)
|
||||
})()
|
||||
`
|
||||
|
||||
/** Total/max on-screen movement of the bottom turn after it first paints. */
|
||||
function measureLoad(samples) {
|
||||
const painted = samples.findIndex(s => s.turns > 0)
|
||||
const after = painted === -1 ? [] : samples.slice(painted)
|
||||
const load = { maxShiftPx: 0, offBottomFrames: 0, settledMs: 0, shiftedPx: 0, shifts: 0 }
|
||||
let previous = null
|
||||
|
||||
for (const sample of after) {
|
||||
if (sample.sh - (sample.st + sample.ch) > 2) {
|
||||
load.offBottomFrames += 1
|
||||
}
|
||||
|
||||
if (previous?.bottomTop != null && sample.bottomTop != null) {
|
||||
const delta = Math.abs(sample.bottomTop - previous.bottomTop)
|
||||
|
||||
if (delta > SHIFT_PX) {
|
||||
load.maxShiftPx = Math.max(load.maxShiftPx, delta)
|
||||
load.settledMs = sample.t
|
||||
load.shiftedPx += delta
|
||||
load.shifts += 1
|
||||
}
|
||||
}
|
||||
|
||||
previous = sample
|
||||
}
|
||||
|
||||
return load
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'session-load',
|
||||
tier: 'backend',
|
||||
description: 'Visual stability of opening a session: how far the transcript moves after first paint.',
|
||||
async run(cdp, opts = {}) {
|
||||
const rows = String(opts.rows ?? '2,5,8').split(',').map(Number)
|
||||
const rounds = Number(opts.rounds ?? 2)
|
||||
const watchMs = Number(opts.watchMs ?? 4500)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const loads = []
|
||||
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
for (const index of rows) {
|
||||
await cdp.eval(ARM)
|
||||
|
||||
if (!(await cdp.eval(CLICK(index)))) {
|
||||
continue
|
||||
}
|
||||
|
||||
await sleep(watchMs)
|
||||
const { samples } = await cdp.eval('(() => { window.__SL.stop(); return window.__SL })()')
|
||||
loads.push(measureLoad(samples))
|
||||
}
|
||||
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
if (!loads.length) {
|
||||
throw new Error('session-load found no sidebar rows to click')
|
||||
}
|
||||
|
||||
const total = key => loads.reduce((sum, load) => sum + load[key], 0)
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
load_max_shift_px: Math.max(...loads.map(load => load.maxShiftPx)),
|
||||
load_off_bottom_frames: Math.round(total('offBottomFrames') / loads.length),
|
||||
load_settled_p95_ms: summarize(loads.map(load => load.settledMs)).p95,
|
||||
load_shifted_px: Math.round(total('shiftedPx') / loads.length)
|
||||
},
|
||||
detail: { loads: loads.length, shiftsPerLoad: total('shifts') / loads.length }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Session-switch latency. Subsumes profile-session-switch. Backend tier: needs
|
||||
// two real stored session ids and a live backend. Report-only.
|
||||
//
|
||||
// node scripts/perf/run.mjs session-switch --a <sidA> --b <sidB> [--rounds 2]
|
||||
|
||||
import { SELECTORS, sleep } from '../lib/cdp.mjs'
|
||||
import { summarize } from '../lib/stats.mjs'
|
||||
|
||||
export default {
|
||||
name: 'session-switch',
|
||||
tier: 'backend',
|
||||
description: 'Route to a session and wait for first-paint + settle of its transcript.',
|
||||
requiredOpts: ['a', 'b'],
|
||||
async run(cdp, opts = {}) {
|
||||
const { a, b } = opts
|
||||
const rounds = Number(opts.rounds ?? 2)
|
||||
const settleTimeoutMs = Number(opts.settleTimeoutMs ?? 30000)
|
||||
|
||||
if (!a || !b) {
|
||||
throw new Error('session-switch needs --a <sessionId> --b <sessionId>')
|
||||
}
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const switchTo = async sid => {
|
||||
const t0 = await cdp.eval(`(() => { location.hash = '#/' + ${JSON.stringify(sid)}; return performance.now() })()`)
|
||||
const deadline = Date.now() + settleTimeoutMs
|
||||
let firstPaint = null
|
||||
let stable = 0
|
||||
let lastCount = -1
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(50)
|
||||
const s = await cdp.eval(`({
|
||||
t: performance.now(),
|
||||
route: location.hash,
|
||||
msgs: document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length
|
||||
})`)
|
||||
|
||||
if (!String(s.route).includes(sid)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (s.msgs > 0 && firstPaint === null) {
|
||||
firstPaint = s.t - t0
|
||||
}
|
||||
|
||||
stable = s.msgs === lastCount && s.msgs > 0 ? stable + 1 : 0
|
||||
lastCount = s.msgs
|
||||
|
||||
if (stable >= 3) {
|
||||
return { firstPaint, settled: s.t - t0 }
|
||||
}
|
||||
}
|
||||
|
||||
return { firstPaint, settled: null }
|
||||
}
|
||||
|
||||
const firstPaints = []
|
||||
const settles = []
|
||||
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
for (const sid of [a, b]) {
|
||||
const r = await switchTo(sid)
|
||||
|
||||
if (typeof r.firstPaint === 'number') firstPaints.push(r.firstPaint)
|
||||
if (typeof r.settled === 'number') settles.push(r.settled)
|
||||
await sleep(800)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
switch_first_paint_p95_ms: summarize(firstPaints).p95,
|
||||
switch_settled_p95_ms: summarize(settles).p95
|
||||
},
|
||||
detail: { rounds, firstPaint: summarize(firstPaints), settled: summarize(settles) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Streaming into an ALREADY-LONG transcript. Same measurement as `stream`, but
|
||||
// the history is mounted and allowed to settle before the recorders start, so
|
||||
// what it captures is the per-delta cost that scales with transcript length —
|
||||
// the regression reported in #69120.
|
||||
//
|
||||
// Report-only (tier: manual): the number depends on how much history the host
|
||||
// can mount, so it is not gated against the committed baseline.
|
||||
|
||||
import stream from './stream.mjs'
|
||||
|
||||
export default {
|
||||
name: 'stream-history',
|
||||
tier: 'manual',
|
||||
description: 'Streaming cost with a long settled transcript already mounted.',
|
||||
run(cdp, opts = {}) {
|
||||
return stream.run(cdp, { ...opts, historyTurns: Number(opts.historyTurns ?? 200) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Streaming render cost. Subsumes measure-synthetic-stream, profile-synth-stream,
|
||||
// profile-long-stream (synthetic) and measure-real-stream / profile-real-stream
|
||||
// (via --real). CPU profiling is provided by the runner's --cpuprofile flag.
|
||||
//
|
||||
// Metrics (lower is better): longtask count + max, frame p95/p99, slow-frame
|
||||
// count, inter-mutation p95. These are what "is streaming smooth?" reduces to.
|
||||
|
||||
import { SELECTORS, sleep, typeIntoComposer } from '../lib/cdp.mjs'
|
||||
import { frameHistogram, percentile } from '../lib/stats.mjs'
|
||||
|
||||
const RECORDERS = `
|
||||
(() => {
|
||||
// Generation guard: a prior run's rAF loop re-reads window.__FT__ each frame,
|
||||
// so simply reassigning it would leave the old loop running and pushing into
|
||||
// the new array (overlapping recorders inflate frame intervals on run 2+).
|
||||
// Bumping the generation makes every stale loop exit on its next tick.
|
||||
window.__FT_GEN__ = (window.__FT_GEN__ || 0) + 1
|
||||
const ftGen = window.__FT_GEN__
|
||||
window.__FT__ = { times: [], stop: false }
|
||||
let last = performance.now()
|
||||
const tick = () => {
|
||||
if (window.__FT_GEN__ !== ftGen || window.__FT__.stop) return
|
||||
const now = performance.now()
|
||||
window.__FT__.times.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__LT__ = { entries: [], stop: false }
|
||||
try {
|
||||
const po = new PerformanceObserver((list) => {
|
||||
if (window.__LT__.stop) return
|
||||
for (const e of list.getEntries()) window.__LT__.entries.push({ duration: e.duration, startTime: e.startTime })
|
||||
})
|
||||
po.observe({ entryTypes: ['longtask'] })
|
||||
window.__LT__.po = po
|
||||
} catch {}
|
||||
|
||||
window.__MO__ = { mutations: [], stop: false, current: null }
|
||||
window.__MO__.arm = () => {
|
||||
const all = document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)})
|
||||
const last = all[all.length - 1]
|
||||
if (!last || last === window.__MO__.current) return
|
||||
window.__MO__.current = last
|
||||
window.__MO__.obs && window.__MO__.obs.disconnect()
|
||||
const obs = new MutationObserver(() => {
|
||||
if (window.__MO__.stop) return
|
||||
window.__MO__.mutations.push({ t: performance.now(), len: last.textContent.length })
|
||||
})
|
||||
obs.observe(last, { childList: true, subtree: true, characterData: true })
|
||||
window.__MO__.obs = obs
|
||||
}
|
||||
return 'armed'
|
||||
})()
|
||||
`
|
||||
|
||||
const COLLECT = `
|
||||
(() => {
|
||||
window.__FT__.stop = true
|
||||
window.__LT__.stop = true
|
||||
window.__MO__.stop = true
|
||||
try { window.__LT__.po && window.__LT__.po.disconnect() } catch {}
|
||||
try { window.__MO__.obs && window.__MO__.obs.disconnect() } catch {}
|
||||
return JSON.stringify({
|
||||
frames: window.__FT__.times,
|
||||
longtasks: window.__LT__.entries,
|
||||
mutations: window.__MO__.mutations
|
||||
})
|
||||
})()
|
||||
`
|
||||
|
||||
function analyze(data, warmupMs, extra = {}) {
|
||||
// Drop warm-up frames (recorder installs before the stream starts).
|
||||
const frames = []
|
||||
let acc = 0
|
||||
|
||||
for (const f of data.frames) {
|
||||
acc += f
|
||||
|
||||
if (acc >= warmupMs) {
|
||||
frames.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
const interMut = []
|
||||
|
||||
for (let i = 1; i < data.mutations.length; i++) {
|
||||
interMut.push(data.mutations[i].t - data.mutations[i - 1].t)
|
||||
}
|
||||
|
||||
const ltDurations = data.longtasks.map(e => e.duration)
|
||||
const windowS = frames.reduce((a, b) => a + b, 0) / 1000
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
longtasks_n: data.longtasks.length,
|
||||
longtask_max_ms: Math.round((ltDurations.length ? Math.max(...ltDurations) : 0) * 10) / 10,
|
||||
frame_p95_ms: Math.round(percentile(frames, 0.95) * 10) / 10,
|
||||
frame_p99_ms: Math.round(percentile(frames, 0.99) * 10) / 10,
|
||||
slow_frames_33: frames.filter(f => f > 33).length,
|
||||
intermut_p95_ms: Math.round(percentile(interMut, 0.95) * 10) / 10
|
||||
},
|
||||
detail: {
|
||||
...extra,
|
||||
windowS: Math.round(windowS * 10) / 10,
|
||||
avgFps: windowS ? Math.round((frames.length / windowS) * 10) / 10 : 0,
|
||||
frameHistogram: frameHistogram(frames),
|
||||
mutations: data.mutations.length,
|
||||
finalLen: data.mutations.at(-1)?.len ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'stream',
|
||||
tier: 'ci',
|
||||
description: 'Assistant-message streaming: longtasks, frame pacing, mutation cadence.',
|
||||
async run(cdp, opts = {}) {
|
||||
const tokens = Number(opts.tokens ?? 400)
|
||||
const intervalMs = Number(opts.intervalMs ?? 16)
|
||||
const flushMinMs = Number(opts.flushMinMs ?? 33)
|
||||
// Realistic default: a short markdown paragraph ending in a blank line, so
|
||||
// blocks SETTLE as they stream — exactly how real LLM output behaves, and
|
||||
// what block-memoization is designed for (only the growing tail re-renders).
|
||||
// A chunk with NO paragraph break (e.g. `--chunk 'word '`) instead grows one
|
||||
// ever-larger block that re-renders fully every flush — a useful worst-case
|
||||
// stress, but not the typical number. No raw autolink (avoids DNS/link-embed
|
||||
// noise unrelated to render cost).
|
||||
const chunk = opts.chunk ?? 'A streamed sentence with **bold**, `code`, and ordinary prose like a normal reply.\n\n'
|
||||
const real = Boolean(opts.real)
|
||||
const historyTurns = Number(opts.historyTurns ?? 0)
|
||||
const historySettleMs = Number(opts.historySettleMs ?? 1500)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
// Mount the settled history BEFORE the recorders start, so the measurement
|
||||
// window contains only streaming work — not the one-off mount cost.
|
||||
if (historyTurns > 0) {
|
||||
if (real) {
|
||||
throw new Error('--historyTurns is only supported by the synthetic stream path')
|
||||
}
|
||||
|
||||
await cdp.eval(`window.__PERF_DRIVE__.loadTranscript(${historyTurns})`)
|
||||
await sleep(historySettleMs)
|
||||
|
||||
const mounted = Number(await cdp.eval('window.__PERF_DRIVE__.snapshotMsgs()'))
|
||||
const expected = historyTurns * 2
|
||||
|
||||
if (mounted !== expected) {
|
||||
throw new Error(`expected ${expected} preloaded history messages, got ${mounted}`)
|
||||
}
|
||||
}
|
||||
|
||||
await cdp.eval(RECORDERS)
|
||||
|
||||
if (real) {
|
||||
// Backend path: fire a real prompt and wait for the stream to appear.
|
||||
const baseCount = await cdp.eval(
|
||||
`document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length`
|
||||
)
|
||||
await typeIntoComposer(cdp, opts.prompt ?? 'count from 1 to 80, one number per line', { cps: 40 })
|
||||
await cdp.eval(`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
|
||||
el && el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }))
|
||||
})()`)
|
||||
|
||||
const deadline = Date.now() + Number(opts.timeoutMs ?? 60000)
|
||||
let started = false
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(50)
|
||||
const n = await cdp.eval(`document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length`)
|
||||
|
||||
if (n > baseCount) {
|
||||
started = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
throw new Error('real stream never started (no LLM credit / backend?)')
|
||||
}
|
||||
|
||||
await cdp.eval('window.__MO__.arm()')
|
||||
// Let it run to completion or timeout.
|
||||
const runDeadline = Date.now() + Number(opts.runMs ?? 30000)
|
||||
|
||||
while (Date.now() < runDeadline) {
|
||||
await sleep(250)
|
||||
const busy = await cdp.eval(`!!document.querySelector('[data-status="running"], [data-busy="true"]')`)
|
||||
|
||||
if (!busy) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Synthetic path: drive $messages directly. No LLM, no credits.
|
||||
await cdp.eval(
|
||||
`window.__PERF_DRIVE__.stream({ chunk: ${JSON.stringify(chunk)}, intervalMs: ${intervalMs}, totalTokens: ${tokens}, flushMinMs: ${flushMinMs} })`
|
||||
)
|
||||
await sleep(200)
|
||||
await cdp.eval('window.__MO__.arm()')
|
||||
await sleep(tokens * intervalMs + 1500)
|
||||
}
|
||||
|
||||
const data = JSON.parse(await cdp.eval(COLLECT))
|
||||
|
||||
if (!real) {
|
||||
await cdp.eval('window.__PERF_DRIVE__.reset()')
|
||||
}
|
||||
|
||||
return analyze(data, real ? 0 : 500, { historyTurns })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Submit (Enter) latency + scroll stability. Subsumes measure-submit and
|
||||
// measure-jump. Backend tier: fires a REAL prompt, so run it on a throwaway
|
||||
// session with a live backend. Report-only (no committed baseline — real
|
||||
// round-trips are too environment-dependent to gate).
|
||||
|
||||
import { SELECTORS, sleep, typeIntoComposer } from '../lib/cdp.mjs'
|
||||
import { summarize } from '../lib/stats.mjs'
|
||||
|
||||
const MEASURE = `
|
||||
new Promise((resolve) => {
|
||||
const composer = document.querySelector(${JSON.stringify(SELECTORS.composer)})
|
||||
const thread = document.querySelector(${JSON.stringify(SELECTORS.threadContent)}) ||
|
||||
document.querySelector(${JSON.stringify(SELECTORS.threadViewport)})
|
||||
const viewport = document.querySelector(${JSON.stringify(SELECTORS.threadViewport)})
|
||||
const startCount = thread ? thread.querySelectorAll(${JSON.stringify(SELECTORS.turnPair)}).length : 0
|
||||
const startScroll = viewport ? viewport.scrollTop : 0
|
||||
const m = { start: performance.now(), maxJumpPx: 0 }
|
||||
let done = false
|
||||
|
||||
const finish = (reason) => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearTimeout(timer); composerObs.disconnect(); threadObs && threadObs.disconnect()
|
||||
m.reason = reason
|
||||
resolve(m)
|
||||
}
|
||||
|
||||
const composerObs = new MutationObserver(() => {
|
||||
if (!m.composerClearedMs && composer && composer.innerText.length === 0) {
|
||||
m.composerClearedMs = performance.now() - m.start
|
||||
}
|
||||
})
|
||||
composer && composerObs.observe(composer, { childList: true, subtree: true, characterData: true })
|
||||
|
||||
let threadObs = null
|
||||
if (thread) {
|
||||
threadObs = new MutationObserver(() => {
|
||||
if (viewport) m.maxJumpPx = Math.max(m.maxJumpPx, Math.abs(viewport.scrollTop - startScroll))
|
||||
const c = thread.querySelectorAll(${JSON.stringify(SELECTORS.turnPair)}).length
|
||||
if (!m.userMsgRenderedMs && c > startCount) {
|
||||
m.userMsgRenderedMs = performance.now() - m.start
|
||||
requestAnimationFrame(() => { m.userMsgPaintMs = performance.now() - m.start; finish('paint') })
|
||||
}
|
||||
})
|
||||
threadObs.observe(thread, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => finish('timeout'), 5000)
|
||||
composer && composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }))
|
||||
})
|
||||
`
|
||||
|
||||
export default {
|
||||
name: 'submit',
|
||||
tier: 'backend',
|
||||
description: 'Enter → composer cleared → user message painted, plus scroll jump.',
|
||||
async run(cdp, opts = {}) {
|
||||
const rounds = Number(opts.rounds ?? 3)
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const clears = []
|
||||
const paints = []
|
||||
const jumps = []
|
||||
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
await typeIntoComposer(cdp, `perf submit round ${i} ${'x'.repeat(30)}`, { cps: 60 })
|
||||
await sleep(250)
|
||||
const m = await cdp.eval(MEASURE)
|
||||
|
||||
if (typeof m.composerClearedMs === 'number') clears.push(m.composerClearedMs)
|
||||
if (typeof m.userMsgPaintMs === 'number') paints.push(m.userMsgPaintMs)
|
||||
jumps.push(m.maxJumpPx ?? 0)
|
||||
|
||||
// Let the turn finish before the next round so they don't pile up.
|
||||
await sleep(4000)
|
||||
}
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
submit_clear_p95_ms: summarize(clears).p95,
|
||||
submit_paint_p95_ms: summarize(paints).p95,
|
||||
submit_scroll_jump_max_px: Math.max(0, ...jumps)
|
||||
},
|
||||
detail: { rounds, clears: summarize(clears), paints: summarize(paints) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Large-transcript mount cost. New scenario (no prior script measured this):
|
||||
// loads N synthetic turns of mixed markdown into $messages and records the
|
||||
// mount→paint time plus any longtasks the mount blocks the main thread with.
|
||||
// This is the "open a long session" path — a first-impression latency.
|
||||
|
||||
import { sleep } from '../lib/cdp.mjs'
|
||||
|
||||
const OBSERVE = `
|
||||
(() => {
|
||||
window.__TM__ = { longtasks: [] }
|
||||
try {
|
||||
const po = new PerformanceObserver((l) => {
|
||||
for (const e of l.getEntries()) window.__TM__.longtasks.push(e.duration)
|
||||
})
|
||||
po.observe({ entryTypes: ['longtask'] })
|
||||
window.__TM__.po = po
|
||||
} catch {}
|
||||
return 'observing'
|
||||
})()
|
||||
`
|
||||
|
||||
export default {
|
||||
name: 'transcript',
|
||||
tier: 'ci',
|
||||
description: 'Mount + paint cost of loading a long transcript.',
|
||||
async run(cdp, opts = {}) {
|
||||
const turns = Number(opts.turns ?? 200)
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.eval(OBSERVE)
|
||||
|
||||
const mountMs = await cdp.eval(`window.__PERF_DRIVE__.loadTranscript(${turns})`)
|
||||
|
||||
// Let post-mount longtasks (content-visibility passes, virtualizer) settle.
|
||||
await sleep(1500)
|
||||
|
||||
const longtasks = await cdp.eval('window.__TM__.longtasks')
|
||||
await cdp.eval('try { window.__TM__.po && window.__TM__.po.disconnect() } catch {}')
|
||||
await cdp.eval('window.__PERF_DRIVE__.reset()')
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
transcript_mount_ms: Math.round(mountMs * 10) / 10,
|
||||
transcript_longtask_ms: Math.round(longtasks.reduce((a, b) => a + b, 0) * 10) / 10,
|
||||
transcript_longtask_max_ms: Math.round((longtasks.length ? Math.max(...longtasks) : 0) * 10) / 10
|
||||
},
|
||||
detail: { turns, messages: turns * 2, longtasks: longtasks.length }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Launch a standalone, fully isolated perf instance and leave it running so you
|
||||
// can attach the harness (`npm run perf`) or DevTools to it. Ctrl-C tears it
|
||||
// down and removes its temp dirs.
|
||||
//
|
||||
// npm run perf:serve # :9222, temp HERMES_HOME + user-data-dir
|
||||
// PERF_PORT=9333 npm run perf:serve # custom CDP port
|
||||
//
|
||||
// This is the isolation seam: because it uses its own --user-data-dir the
|
||||
// Electron single-instance lock never collides with a running `hgui`.
|
||||
|
||||
import { startIsolatedInstance } from './lib/launch.mjs'
|
||||
|
||||
const port = Number(process.env.PERF_PORT ?? 9222)
|
||||
const devPort = Number(process.env.PERF_DEV_PORT ?? 5174)
|
||||
|
||||
console.log(`[perf:serve] starting isolated instance (CDP :${port}, dev :${devPort})…`)
|
||||
|
||||
const instance = await startIsolatedInstance({
|
||||
port,
|
||||
devPort,
|
||||
hermesHome: process.env.PERF_HERMES_HOME,
|
||||
userDataDir: process.env.PERF_USER_DATA
|
||||
})
|
||||
|
||||
console.log(`[perf:serve] READY — attach with: npm run perf -- --port ${port}`)
|
||||
|
||||
let closing = false
|
||||
const shutdown = () => {
|
||||
if (closing) {
|
||||
return
|
||||
}
|
||||
|
||||
closing = true
|
||||
console.log('\n[perf:serve] tearing down…')
|
||||
instance.teardown()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
@@ -0,0 +1,146 @@
|
||||
// ⌘K open latency, measured in-page (no CDP round-trip in the number).
|
||||
//
|
||||
// node scripts/probe-command-palette.mjs [--port 9222] [--rounds 8]
|
||||
//
|
||||
// Reports, per round, the time from the keydown the app actually receives to:
|
||||
// frame_ms — the dialog frame + input in the DOM and painted (what "instant"
|
||||
// means: the overlay owes you a frame immediately)
|
||||
// rows_ms — the row list painted (may lag frame_ms; rows are deferred)
|
||||
// plus any long tasks in the window, so a slow open is attributable.
|
||||
import { CDP, sleep } from './perf/lib/cdp.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = name => {
|
||||
const i = args.indexOf(`--${name}`)
|
||||
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
|
||||
const port = Number(flag('port') ?? 9222)
|
||||
const rounds = Number(flag('rounds') ?? 8)
|
||||
|
||||
const cdp = await CDP.connect({ port })
|
||||
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const INSTALL = `
|
||||
(() => {
|
||||
if (window.__CMDK__) window.__CMDK__.stop()
|
||||
|
||||
const state = { t0: null, frame: null, rows: 0, rowsAt: null, tasks: [], armed: false }
|
||||
|
||||
// Time from the keydown the APP receives — excludes CDP transport, so the
|
||||
// number is what a user's finger actually experiences.
|
||||
const onKey = e => {
|
||||
if (state.armed && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
state.t0 = performance.now()
|
||||
state.armed = false
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
|
||||
const obs = new MutationObserver(() => {
|
||||
if (state.t0 === null) return
|
||||
if (state.frame === null && document.querySelector('[cmdk-input]')) {
|
||||
state.frame = performance.now() - state.t0
|
||||
}
|
||||
const n = document.querySelectorAll('[cmdk-item]').length
|
||||
if (n > state.rows) { state.rows = n; state.rowsAt = performance.now() - state.t0 }
|
||||
})
|
||||
|
||||
obs.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
const po = new PerformanceObserver(list => {
|
||||
for (const e of list.getEntries()) state.tasks.push({ start: e.startTime, dur: Math.round(e.duration) })
|
||||
})
|
||||
|
||||
try { po.observe({ entryTypes: ['longtask'] }) } catch {}
|
||||
|
||||
window.__CMDK__ = {
|
||||
arm: () => { state.t0 = null; state.frame = null; state.rows = 0; state.rowsAt = null; state.tasks = []; state.armed = true },
|
||||
read: () => ({
|
||||
frame_ms: state.frame === null ? -1 : Math.round(state.frame),
|
||||
rows_ms: state.rowsAt === null ? -1 : Math.round(state.rowsAt),
|
||||
rows: state.rows,
|
||||
longtask_ms: state.t0 === null ? 0 : state.tasks.filter(t => t.start >= state.t0).reduce((s, t) => s + t.dur, 0)
|
||||
}),
|
||||
stop: () => { window.removeEventListener('keydown', onKey, true); obs.disconnect(); po.disconnect() }
|
||||
}
|
||||
|
||||
return true
|
||||
})()
|
||||
`
|
||||
|
||||
// Settle: frame painted AND rows stopped growing for two frames.
|
||||
const WAIT = `
|
||||
new Promise(resolve => {
|
||||
let stable = 0
|
||||
let last = -1
|
||||
const started = performance.now()
|
||||
const tick = () => {
|
||||
const r = window.__CMDK__.read()
|
||||
if (r.frame_ms >= 0 && r.rows === last && r.rows > 0) {
|
||||
if (++stable >= 2) { resolve(r); return }
|
||||
} else { stable = 0 }
|
||||
last = r.rows
|
||||
if (performance.now() - started > 8000) { resolve(window.__CMDK__.read()); return }
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
})
|
||||
`
|
||||
|
||||
const key = async type =>
|
||||
cdp.send('Input.dispatchKeyEvent', {
|
||||
type,
|
||||
key: 'k',
|
||||
code: 'KeyK',
|
||||
windowsVirtualKeyCode: 75,
|
||||
nativeVirtualKeyCode: 75,
|
||||
modifiers: 4
|
||||
})
|
||||
|
||||
const esc = async () => {
|
||||
for (const type of ['keyDown', 'keyUp']) {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
|
||||
}
|
||||
|
||||
await sleep(400)
|
||||
}
|
||||
|
||||
await cdp.eval(INSTALL)
|
||||
await esc()
|
||||
|
||||
const samples = []
|
||||
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
await sleep(250)
|
||||
await cdp.eval('window.__CMDK__.arm()')
|
||||
await key('rawKeyDown')
|
||||
await key('keyUp')
|
||||
const r = await cdp.eval(WAIT)
|
||||
samples.push(r)
|
||||
console.log(`round ${i}:`, r)
|
||||
await esc()
|
||||
}
|
||||
|
||||
await cdp.eval('window.__CMDK__.stop()')
|
||||
|
||||
const stat = k => {
|
||||
const v = samples.map(s => s[k]).filter(n => n >= 0).sort((a, b) => a - b)
|
||||
|
||||
if (!v.length) return null
|
||||
|
||||
return {
|
||||
min: v[0],
|
||||
median: v[Math.floor(v.length / 2)],
|
||||
max: v[v.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nkeydown → dialog frame painted (ms):', stat('frame_ms'))
|
||||
console.log('keydown → rows painted (ms):', stat('rows_ms'))
|
||||
console.log('long-task time in window (ms):', stat('longtask_ms'))
|
||||
|
||||
cdp.close()
|
||||
@@ -0,0 +1,80 @@
|
||||
// Model picker open latency probe: click the composer model pill, time until
|
||||
// the dropdown/dialog content paints, repeat. Run with --cpuprofile via the
|
||||
// harness runner once promoted; standalone for iteration.
|
||||
// node scripts/perf/probe-model-picker.mjs [--port 9222] [--rounds 5]
|
||||
import { CDP } from './perf/lib/cdp.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = name => {
|
||||
const i = args.indexOf(`--${name}`)
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
const port = Number(flag('port') ?? 9222)
|
||||
const rounds = Number(flag('rounds') ?? 5)
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
const cdp = await CDP.connect({ port })
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
// Find the pill (dropdown path) — the composer model selector button.
|
||||
const PILL = `(() => {
|
||||
const btns = [...document.querySelectorAll('button[aria-label]')]
|
||||
const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || '') && b.closest('[data-slot]'))
|
||||
return pill ? (pill.getAttribute('aria-label') || 'found') : null
|
||||
})()`
|
||||
|
||||
console.log('pill:', await cdp.eval(PILL))
|
||||
|
||||
const MEASURE = `
|
||||
(async () => {
|
||||
const btns = [...document.querySelectorAll('button[aria-label]')]
|
||||
const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || ''))
|
||||
if (!pill) return JSON.stringify({ error: 'no pill' })
|
||||
|
||||
const t0 = performance.now()
|
||||
pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
pill.click()
|
||||
|
||||
// Wait for menu/dialog content to exist AND paint (double rAF after found).
|
||||
const found = await new Promise(resolve => {
|
||||
const deadline = performance.now() + 5000
|
||||
const check = () => {
|
||||
const menu = document.querySelector('[role="menu"], [role="dialog"] [cmdk-list]')
|
||||
if (menu && menu.childElementCount > 0) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now())))
|
||||
return
|
||||
}
|
||||
if (performance.now() > deadline) { resolve(null); return }
|
||||
requestAnimationFrame(check)
|
||||
}
|
||||
check()
|
||||
})
|
||||
|
||||
const openMs = found ? found - t0 : null
|
||||
const rows = document.querySelectorAll('[role="menu"] [role="menuitem"], [cmdk-item]').length
|
||||
|
||||
// Close: Escape.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
const menu = document.querySelector('[role="menu"], [role="dialog"]')
|
||||
menu?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
return JSON.stringify({ openMs, rows })
|
||||
})()
|
||||
`
|
||||
|
||||
const samples = []
|
||||
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
const raw = await cdp.eval(MEASURE, { awaitPromise: true })
|
||||
const r = JSON.parse(raw)
|
||||
console.log(`round ${i}:`, r)
|
||||
if (typeof r.openMs === 'number') samples.push(r.openMs)
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
samples.sort((a, b) => a - b)
|
||||
console.log('\nopen latency ms — min/median/max:',
|
||||
Math.round(samples[0]), '/', Math.round(samples[Math.floor(samples.length / 2)]), '/', Math.round(samples.at(-1)))
|
||||
cdp.close()
|
||||
@@ -0,0 +1,38 @@
|
||||
// quick probe — read state of the renderer
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
console.log('target:', tgt?.url)
|
||||
if (!tgt) process.exit(1)
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (method, params = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method, params }))
|
||||
})
|
||||
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: `({
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
rootChildren: document.getElementById('root')?.children.length ?? 0,
|
||||
rootInner: (document.getElementById('root')?.innerHTML ?? '').slice(0, 300),
|
||||
hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'),
|
||||
bootStage: (document.querySelector('[data-slot*="boot"]')?.getAttribute('data-slot')) ?? null,
|
||||
bodyText: document.body.innerText.slice(0, 300),
|
||||
errorCount: window.__errors?.length ?? 'n/a'
|
||||
})`,
|
||||
returnByValue: true
|
||||
})
|
||||
console.log('raw:', JSON.stringify(r, null, 2))
|
||||
ws.close()
|
||||
@@ -0,0 +1,40 @@
|
||||
// Probe the cloud shadows thread state — count messages, turn pairs,
|
||||
// thread height, composer state
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (m, p = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method: m, params: p }))
|
||||
})
|
||||
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: `JSON.stringify({
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
turnPairs: document.querySelectorAll('[data-slot="aui_turn-pair"]').length,
|
||||
assistantMsgs: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length,
|
||||
userMsgs: document.querySelectorAll('[data-message-role="user"], [data-slot="aui_user-message-root"]').length,
|
||||
totalDomNodes: document.querySelectorAll('*').length,
|
||||
threadViewportScrollHeight: document.querySelector('[data-slot="aui_thread-viewport"]')?.scrollHeight ?? null,
|
||||
threadViewportClientHeight: document.querySelector('[data-slot="aui_thread-viewport"]')?.clientHeight ?? null,
|
||||
threadViewportScrollTop: document.querySelector('[data-slot="aui_thread-viewport"]')?.scrollTop ?? null,
|
||||
composer: !!document.querySelector('[data-slot="composer-rich-input"]'),
|
||||
busy: !!document.querySelector('[aria-label*="Stop"]')
|
||||
})`,
|
||||
returnByValue: true
|
||||
})
|
||||
console.log(JSON.parse(r.result.result.value))
|
||||
ws.close()
|
||||
@@ -0,0 +1,60 @@
|
||||
// CPU-profile one model-picker open.
|
||||
// node scripts/profile-model-picker.mjs [--port 9222]
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
import { CDP } from './perf/lib/cdp.mjs'
|
||||
import { cpuProfileTopSelf } from './perf/lib/stats.mjs'
|
||||
|
||||
const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222)
|
||||
const cdp = await CDP.connect({ port })
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.send('Profiler.enable')
|
||||
await cdp.send('Profiler.setSamplingInterval', { interval: 100 })
|
||||
|
||||
const OPEN = `
|
||||
(async () => {
|
||||
// Reset: close any open menu first.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
|
||||
const btns = [...document.querySelectorAll('button[aria-label]')]
|
||||
const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || ''))
|
||||
if (!pill) return -1
|
||||
const t0 = performance.now()
|
||||
pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
|
||||
pill.click()
|
||||
const found = await new Promise(resolve => {
|
||||
const deadline = performance.now() + 8000
|
||||
const check = () => {
|
||||
const menu = document.querySelector('[role="menu"]')
|
||||
if (menu && menu.childElementCount > 0) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now())))
|
||||
return
|
||||
}
|
||||
if (performance.now() > deadline) { resolve(-1); return }
|
||||
requestAnimationFrame(check)
|
||||
}
|
||||
check()
|
||||
})
|
||||
return found < 0 ? -1 : found - t0
|
||||
})()
|
||||
`
|
||||
|
||||
await cdp.send('Profiler.start')
|
||||
const openMs = await cdp.eval(OPEN)
|
||||
const { profile } = await cdp.send('Profiler.stop')
|
||||
|
||||
console.log('openMs:', Math.round(openMs))
|
||||
const out = `/tmp/model-picker-open.cpuprofile`
|
||||
writeFileSync(out, JSON.stringify(profile))
|
||||
console.log('wrote', out)
|
||||
console.log('top self-time (ms):')
|
||||
|
||||
for (const r of cpuProfileTopSelf(profile, 20)) {
|
||||
console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(44)} ${r.url.split('/').slice(-2).join('/')}:${r.line}`)
|
||||
}
|
||||
|
||||
// Close the menu again.
|
||||
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
|
||||
cdp.close()
|
||||
@@ -0,0 +1,388 @@
|
||||
# Profiling renderer typing lag
|
||||
|
||||
Workflow for empirically measuring (and fixing) typing/submit lag in the
|
||||
desktop chat composer.
|
||||
|
||||
> **Note (Jul 2026):** the standalone `measure-*` / `profile-*` scripts this
|
||||
> doc references have been consolidated into the systematized perf harness at
|
||||
> `scripts/perf/` (`npm run perf`, `npm run perf:serve`). CPU profiling is now a
|
||||
> `--cpuprofile` flag on any scenario. This doc is kept as an investigation log;
|
||||
> for the current tooling and the scenario→old-script mapping see
|
||||
> `scripts/perf/README.md`.
|
||||
|
||||
## Quick boot for profiling
|
||||
|
||||
Vite 8 + plugin-react 6 has a known issue where the React Fast Refresh
|
||||
preamble script isn't injected into `index.html`, so opening Electron at
|
||||
`http://127.0.0.1:5174` throws `$RefreshReg$ is not defined` on every TSX
|
||||
module and the React tree never mounts. Workaround: run vite with HMR off.
|
||||
|
||||
```bash
|
||||
# Terminal A — start dev server without HMR
|
||||
cd apps/desktop
|
||||
node scripts/dev-no-hmr.mjs
|
||||
|
||||
# Terminal B — start Electron with CDP exposed
|
||||
cd apps/desktop
|
||||
XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 \
|
||||
../../node_modules/.bin/electron --remote-debugging-port=9222 .
|
||||
```
|
||||
|
||||
Terminal C is yours to run the harnesses.
|
||||
|
||||
## Harnesses
|
||||
|
||||
All zero-dep — Node 24 built-in `WebSocket` + `fetch`.
|
||||
|
||||
### Typing latency — `measure-latency.mjs`
|
||||
|
||||
Per-keystroke `keypress → next paint` latency, p50/p90/p99/max.
|
||||
Synthesizes keystrokes via `Input.dispatchKeyEvent` so the run is
|
||||
reproducible.
|
||||
|
||||
```bash
|
||||
node apps/desktop/scripts/measure-latency.mjs --chars=120 --cps=20
|
||||
```
|
||||
|
||||
Anything > 16ms is a dropped frame. On a freshly-loaded session
|
||||
(`scripts/click-session.mjs 'Phaser particle'`) we currently see:
|
||||
|
||||
| | unpatched | patched |
|
||||
|---|---|---|
|
||||
| p50 paint | 1.9 ms | 2.0 ms |
|
||||
| p90 paint | 3.3 ms | 13.7 ms |
|
||||
| p99 paint | 16.7 ms | 15.2 ms |
|
||||
| max paint | 20.5 ms | 30.4 ms |
|
||||
| >16ms drops | 2/120 | 1/120 |
|
||||
|
||||
Roughly even on a quick session — patches don't fix typing latency
|
||||
under benign synthetic conditions because the existing baseline is
|
||||
already snappy on synthetic input. The real wins are in the leak counters
|
||||
(see below). If the user reports typing jank, capture a profile + heap
|
||||
diff during their actual usage and compare against the synthetic baseline
|
||||
to identify what condition (long thread, popover open, paste, etc.)
|
||||
makes the path slow.
|
||||
|
||||
### Leak counters — `leak-typing.mjs`
|
||||
|
||||
Types N chars per round, clears, force-GCs, captures
|
||||
`Performance.getMetrics` deltas. Reveals leaked event listeners, heap
|
||||
drift, document node growth, and forced-layout counts.
|
||||
|
||||
```bash
|
||||
# After clicking into a real session (e.g. via click-session.mjs):
|
||||
node apps/desktop/scripts/leak-typing.mjs --rounds=8 --chars=200 --cps=50
|
||||
```
|
||||
|
||||
**Real-session numbers (Phaser thread, 8 rounds × 200 chars):**
|
||||
|
||||
| | unpatched (HEAD~2) | patched (HEAD) |
|
||||
|---|---|---|
|
||||
| jsListeners growth/round | +0 | +0 |
|
||||
| DOM nodes growth/round | +0 | +0 |
|
||||
| heap growth/round | ~0 (V8 housekeeping) | ~0 |
|
||||
| **forced layouts/char** | **7.02** | **2.35** (3× fewer) |
|
||||
|
||||
The forced-layout count is the load-bearing number — typing into a real
|
||||
session was triggering ~7 layouts per character on the unpatched build
|
||||
(scrollHeight reads + per-px CSS var writes + FadeText scrollWidth reads
|
||||
all stacking up). After the patches it's down to ~2.35/char, which is
|
||||
Blink's natural cost for a 1px/char-growing contentEditable and can't
|
||||
be lowered further without architectural changes.
|
||||
|
||||
The initial "+35 listeners/round leak" I called out on the first
|
||||
unpatched run turned out to be transient warm-up (popovers initializing,
|
||||
etc.); steady-state listener growth was 0 both before and after.
|
||||
|
||||
### CPU profile + heap snapshot — `profile-typing.mjs`
|
||||
|
||||
Records a CPU profile while typing, plus before/after heap snapshots so
|
||||
you can do a comparison diff in Chrome DevTools Memory tab.
|
||||
|
||||
```bash
|
||||
node apps/desktop/scripts/profile-typing.mjs \
|
||||
--chars=400 --cps=30 --out=/tmp/hermes-typing
|
||||
# → /tmp/hermes-typing.cpuprofile (open in Chrome DevTools Performance)
|
||||
# → /tmp/hermes-typing.before.heapsnapshot
|
||||
# → /tmp/hermes-typing.after.heapsnapshot
|
||||
```
|
||||
|
||||
Loading the cpuprofile: Chrome DevTools → Performance tab → drag the file
|
||||
in, or VS Code → open the `.cpuprofile` directly.
|
||||
|
||||
For heap diff: Chrome DevTools → Memory → Load snapshot → load "before",
|
||||
then Comparison view → load "after". Sort by `# Delta`. Stay alert for
|
||||
detached DOM, FiberNodes (unmounted), and listener growth.
|
||||
|
||||
## Helpers
|
||||
|
||||
- `probe-renderer.mjs` — dump page state (URL, composer mounted?, body text)
|
||||
- `click-session.mjs <title>` — click a sidebar session by partial title match
|
||||
- `reload-renderer.mjs` — force Page.reload via CDP (no HMR available)
|
||||
- `dump-state.mjs` — richer state dump (thread message count, sticky session, etc.)
|
||||
- `probe-console.mjs` — dump recent console errors / exceptions
|
||||
|
||||
## Findings
|
||||
|
||||
See commit message for `apps/desktop/src/app/chat/composer/index.tsx`
|
||||
edits. Three changes:
|
||||
|
||||
1. **Per-keystroke `scrollHeight` read removed.** The expansion useEffect
|
||||
used to read `editorRef.current.scrollHeight` on every draft change
|
||||
(forces synchronous layout). Replaced with a `draft.length > 60`
|
||||
heuristic; the ResizeObserver catches anything the heuristic misses.
|
||||
|
||||
2. **Bucketed CSS custom-property writes.** `syncComposerMetrics`
|
||||
used to `setProperty('--composer-measured-height', height + 'px')`
|
||||
on every observed resize, invalidating computed style for the whole
|
||||
tree. Now writes only when the height crosses an 8 px bucket, so
|
||||
typing in a fixed-height row produces no style invalidation at all.
|
||||
|
||||
3. **Removed dead `$composerDraft` → `aui.composer().setText` round-trip.**
|
||||
Nothing outside the composer subscribed to `$composerDraft` (verified
|
||||
via grep). The two useEffects that pushed draft → store and store →
|
||||
composer were pure overhead per keystroke. `reconcileComposerTerminalSelections`
|
||||
was also called per keystroke; can be deferred to submit time (it's a
|
||||
stale-pruning step, not a correctness one — `terminalContextBlocksFromDraft`
|
||||
walks the current text directly at submit and ignores stale labels).
|
||||
|
||||
4. **`refreshTrigger` fast-bails when no `@`/`/` in draft.** Previously
|
||||
`textBeforeCaret()` did `range.toString()` (O(n)) on every keystroke
|
||||
even when no trigger char was present.
|
||||
|
||||
The biggest win is the listener leak in (3) — without it, each round of
|
||||
typing leaked ~35 event listeners until a steady state.
|
||||
|
||||
## Submit / TTFT stall (open)
|
||||
|
||||
User reports a perceived stall *after* Enter, before the assistant starts
|
||||
streaming. `scripts/measure-submit.mjs` measures
|
||||
`enter → composer-cleared → user-message-rendered → first-paint`. The
|
||||
script triggers a real prompt submission, so use it on a throwaway
|
||||
session. Not enabled in CI.
|
||||
|
||||
## Streaming "5fps" investigation (May 21, 2026)
|
||||
|
||||
User complaint: "the streaming must bring fps to like 5? lol" — felt
|
||||
hitches during assistant streaming on long threads.
|
||||
|
||||
### Tooling added
|
||||
|
||||
- **`src/app/chat/perf-probe.tsx`** — dev-only side-effect import (guarded by
|
||||
`import.meta.env.MODE !== 'production'` in `main.tsx`). Attaches two
|
||||
helpers to `window`:
|
||||
- `__PERF_PROBE__` — React `<Profiler>` recorder. Currently inert because
|
||||
Vite is serving the production React build (see "Vite dev-build issue"
|
||||
below); kept for when that's fixed.
|
||||
- `__PERF_DRIVE__` — synthetic stream driver. Pushes tokens through the
|
||||
live `$messages` atom at a fixed cadence, so the assistant-ui runtime,
|
||||
incremental repository, Streamdown markdown renderer, and React commit
|
||||
pipeline all see the same workload they'd see from a real LLM stream —
|
||||
but with no LLM call (and no credit cost).
|
||||
- **`scripts/measure-synthetic-stream.mjs`** — drives `__PERF_DRIVE__`,
|
||||
records rAF frame intervals, `PerformanceObserver({entryTypes:['longtask']})`
|
||||
entries, `MutationObserver` cadence on the live message, and optional
|
||||
type-while-streaming keystroke latency.
|
||||
- **`scripts/profile-synth-stream.mjs`** — CPU profile during a synthetic
|
||||
stream; writes a `.cpuprofile` (open in Chrome DevTools Performance panel)
|
||||
and a top-30 self-time table.
|
||||
- **`scripts/measure-real-stream.mjs`** — same harness as the synthetic but
|
||||
fires a real LLM prompt. Use when you have credits and want to confirm
|
||||
the synthetic predictions hold.
|
||||
- **`scripts/profile-real-stream.mjs`** — CPU profile over the duration of
|
||||
a real LLM stream.
|
||||
|
||||
Helpers: `scripts/eval.mjs` (one-shot CDP eval), `scripts/reload.mjs`
|
||||
(hard reload renderer over CDP).
|
||||
|
||||
### Findings
|
||||
|
||||
Measured on the Cloud Shadows session (7 turns, ~11k px scrollHeight) and
|
||||
the 34 MB session `session_20260514_215353_fe0ac8.json` (110 FadeText
|
||||
instances, lots of historical tool calls).
|
||||
|
||||
| metric | Cloud Shadows | 34 MB session |
|
||||
|---|---|---|
|
||||
| avgFps (60 tok/sec, 5s) | 60.0 | 58.6 |
|
||||
| frame p50 / p95 / p99 (ms) | 16.7 / 18.0 / 21.1 | 16.6 / 25.6 / 31.4 |
|
||||
| max frame (ms) | 31.1 | 97-127 (varies) |
|
||||
| longtasks per 5s window | 0 | 1-2, 75-127 ms |
|
||||
| type-while-stream p95 latency (ms) | 17 | — |
|
||||
|
||||
A single real-LLM stream on Cloud Shadows (gpt-4o-mini, 39s window) saw
|
||||
12 longtasks totalling 1.26 s — same cadence the synthetic predicted
|
||||
(~1 hitch per 3.25 s, max 123 ms). So the **synthetic stream is a faithful
|
||||
proxy for the real one** and is fine for iterating on fixes without paying
|
||||
for tokens.
|
||||
|
||||
### CPU profile during streaming (synthetic, markdown content)
|
||||
|
||||
Top self-time costs (5 s window, 400 tokens at 125 tok/s, markdown chunks):
|
||||
|
||||
| ms (self) | function | source |
|
||||
|---|---|---|
|
||||
| 260 | `bn$1` | `chunk-BO2N…js:20003` (micromark tokenize) |
|
||||
| 249 | `m$1` | `chunk-BO2N…js:19949` (micromark) |
|
||||
| 128 | `compile` | `chunk-BO2N…js:21884` (mdast → hast compile) |
|
||||
| 73 | FadeText body | `components/ui/fade-text.tsx` |
|
||||
| 62 | `parser` | `chunk-BO2N…js:22680` |
|
||||
| 49 | `fromThreadMessageLike` | `@assistant-ui/internal` |
|
||||
|
||||
That `chunk-BO2N2NFS` is the vendored bundle containing `micromark`,
|
||||
`mdast-util-from-markdown`, `mdast-util-to-hast`, `rehype-raw`,
|
||||
`hast-util-sanitize`, etc. — i.e. **Streamdown's markdown pipeline,
|
||||
re-parsing the entire growing assistant message on every token append**.
|
||||
Cost scales linearly with message length.
|
||||
|
||||
Compare plain-text (no markdown) — the `chunk-BO2N…` entries drop out
|
||||
of the top 30 entirely; total work per 5 s window halves.
|
||||
|
||||
### Fix landed: `FadeText` memo
|
||||
|
||||
`FadeText` is used in `tool-fallback.tsx` (110 instances on a tool-heavy
|
||||
thread). Before: each parent re-render during streaming triggered a
|
||||
`useEffect([children])` that forced a `scrollWidth` layout read — even
|
||||
when the title text was unchanged. The `useResizeObserver` already covers
|
||||
the genuine resize case, so the effect was strictly redundant.
|
||||
|
||||
After: wrapped in `React.memo` with a custom comparator that compares
|
||||
`children` (scalar fast-path), `className`, `fadeWidth`, and `style`
|
||||
field-by-field. Verified via temporary render counter:
|
||||
**122 renders during a 2 s synthetic stream vs ~11 000 without memo**
|
||||
(110 instances × ~100 stream updates). Doesn't move the longtask needle
|
||||
on its own — Streamdown dwarfs it — but eliminates a class of forced
|
||||
layouts and removes a steady CPU floor.
|
||||
|
||||
### Also landed: `MarkdownText` plugins memo + upstream flush floor
|
||||
|
||||
Two smaller follow-ups in the same investigation:
|
||||
|
||||
1. **`MarkdownText` `plugins` object useMemo'd.** The inline
|
||||
`plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}`
|
||||
was constructing a new object on every render, which churns
|
||||
`<Streamdown>`'s outer memo and forces its internal `rehypePlugins` /
|
||||
`remarkPlugins` arrays to rebuild. CPU profile after the change shows
|
||||
`parser` self-time dropping out of the top 10, `compile` cut roughly
|
||||
in half, and `bn$1` / `m$1` (micromark internals) dropping off the
|
||||
top entries.
|
||||
|
||||
2. **`use-message-stream.scheduleDeltaFlush` got a real minimum floor.**
|
||||
Previously the rAF-only path effectively meant "at most one flush per
|
||||
frame," but at typical LLM token rates of 30-80 tok/sec each token
|
||||
arrives slower than rAF cadence and gets its own React commit. With
|
||||
`STREAM_DELTA_FLUSH_MS = 33` (two frames) and a `lastFlushAt`-tracked
|
||||
floor, slower streams now coalesce ~2 tokens per commit, halving
|
||||
markdown re-parses. React's auto-batching already covers part of this
|
||||
probabilistically; the floor makes the batching deterministic so the
|
||||
max-longtask number tightens up.
|
||||
|
||||
A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
|
||||
(3 trials each):
|
||||
|
||||
| | avgFps | p99 frame | LTs/5s | max LT | mutations |
|
||||
|---|---|---|---|---|---|
|
||||
| no throttle | 54.0 | 38 ms | 2.0 | 145 ms | varies (2-112) |
|
||||
| 33 ms throttle | 54.3 | 41 ms | 1.7 | 110 ms | ~135 |
|
||||
|
||||
Modest. `inter-mutation` p50 tightens from 22-28 ms to a clean 33 ms,
|
||||
which is what you'd expect from a deterministic floor.
|
||||
|
||||
### Also landed: `useDeferredValue` at the streamdown-text boundary
|
||||
|
||||
The longtask CPU was unavoidable inside the block-memo pattern — the live
|
||||
tail re-parses every commit, scales linearly with current length, and
|
||||
nothing about Streamdown's architecture changes that without forking. The
|
||||
fix is to stop having that work *block* the main thread.
|
||||
|
||||
`<DeferStreamingText>` in `markdown-text.tsx` is a 12-line wrapper that
|
||||
reads the message-part state via `useMessagePartText`, runs it through
|
||||
`useDeferredValue`, and re-publishes via assistant-ui's
|
||||
`<TextMessagePartProvider>`. The inner `StreamdownTextPrimitive` reads the
|
||||
deferred value through the normal `useMessagePartText` hook — no fork,
|
||||
no internal-path imports, fully on the assistant-ui public API.
|
||||
|
||||
What React's concurrent scheduler now does:
|
||||
|
||||
- When a new token arrives mid-render, the in-flight deferred render
|
||||
is abandoned and a fresh one starts with the latest text.
|
||||
- When the main thread has urgent work (typing, scroll, layout), the
|
||||
Streamdown render gets deprioritized — input stays responsive even
|
||||
while a 100 ms parse is queued.
|
||||
|
||||
Streamdown already uses `useTransition` internally for its block-array
|
||||
setState; `useDeferredValue` here just lifts the deferral all the way up
|
||||
to the consumer text boundary, so the whole pipeline — preprocess,
|
||||
block split, repair, parse, render — runs at low priority during streaming.
|
||||
This is the industry-standard approach (see
|
||||
[Streamdown architecture analysis](https://tigerabrodi.blog/how-to-build-a-performant-ai-markdown-renderer)
|
||||
and Chrome's [LLM-response render best practices](https://developer.chrome.google.cn/docs/ai/render-llm-responses)).
|
||||
|
||||
A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
|
||||
(four trials each, prod-throttle (33 ms) on for both):
|
||||
|
||||
| | avgFps | p99 frame | LTs / 5 s | max LT | typing p95 |
|
||||
|---|---|---|---|---|---|
|
||||
| pre-defer | 54.3 | 41 ms | 1.7 | 110 ms | ~17 ms |
|
||||
| **post-defer** | **58.5** | **31 ms** | 2.0 | 117 ms | 14-18 ms |
|
||||
|
||||
Longtask count and max LT are unchanged — `useDeferredValue` doesn't
|
||||
reduce CPU, only its priority. The avgFps lift and p99 frame drop are
|
||||
the proof that the existing CPU is no longer blocking 60 fps cadence:
|
||||
when React can defer the parse, frames stay clean. One particularly
|
||||
clean run logged **MUTATIONS=0** — React skipped every intermediate
|
||||
text state and only committed the final one, the textbook
|
||||
useDeferredValue behaviour.
|
||||
|
||||
### Not fixed: Streamdown markdown re-parse cost (the elephant)
|
||||
|
||||
Total CPU spent in micromark/mdast/hast pipeline per 5 s window is still
|
||||
the same ~700 ms. With `useDeferredValue` that work no longer blocks
|
||||
input, but if you watch a CPU profile you'll see the same hot functions
|
||||
(`Tn$1`, `bn$1`, `m$1`, `parser`, `compile`).
|
||||
|
||||
The path to actually *reduce* that cost (not just defer it) is to
|
||||
replace the parser with a state machine like
|
||||
[Flowdown](https://github.com/Atomics-hub/flowdown) — process each
|
||||
character exactly once, emit DOM ops directly, no re-parse of the prefix
|
||||
on every token. Claimed ~2,000× over `marked`. Trades: not a
|
||||
`react-markdown`-compatible API, no rehype security pipeline, would
|
||||
require replacing Streamdown wholesale. Worth investigating only if
|
||||
even the deferred work shows up in user-perceptible ways (e.g.
|
||||
trackpad-scrolling a stream-in-progress stutters).
|
||||
|
||||
The synthetic harness now mirrors the real upstream pipeline via the
|
||||
`flushMinMs` option in `__PERF_DRIVE__.stream({ flushMinMs: 33 })`, so
|
||||
future Streamdown / Flowdown experiments can A/B without LLM credit cost.
|
||||
The synthetic numbers tracked the one real-LLM run we caught within
|
||||
noise, so it's a reliable proxy.
|
||||
|
||||
Possible approaches (none implemented here):
|
||||
|
||||
1. **Coalesce/throttle Streamdown updates** — render at most every 32 ms
|
||||
instead of every set-state. Reduces parses but doesn't reduce
|
||||
per-parse cost; trades latency for smoothness.
|
||||
2. **Memoize per-prefix** — diff the new text against the prior parsed
|
||||
version; only re-parse the changed suffix.
|
||||
3. **Render in stable segments** — close-form historical paragraphs as
|
||||
immutable React nodes; only the live tail goes through markdown each
|
||||
token. Probably the highest-impact change but requires forking or
|
||||
patching `@assistant-ui/react-streamdown`.
|
||||
4. **Move parsing to a Web Worker** — main thread no longer blocks on
|
||||
markdown. Largest surgery; requires double-buffered hast.
|
||||
|
||||
### Vite dev-build issue (separate)
|
||||
|
||||
`http://127.0.0.1:5174/node_modules/.vite/deps/react.js` resolves to
|
||||
`react/cjs/react.production.js`, and `react-dom_client.js` →
|
||||
`react-dom-client.production.js`. As a result:
|
||||
|
||||
- `<React.Profiler>` `onRender` is never called (production build is a
|
||||
no-op).
|
||||
- `import.meta.env.DEV` is `false`, `PROD` is `true` even under `vite dev`
|
||||
(hence `MODE !== 'production'` as the workaround in `main.tsx`).
|
||||
- All the React 19 dev-only warnings/devtools backend hooks are absent.
|
||||
|
||||
Root cause likely sits in `vite.config.ts` aliasing + dedupe + Vite 8's
|
||||
new `optimizeDeps` defaults. Worth a separate fix pass — when it's
|
||||
resolved, the `<PerfProbe>` blocks in `perf-probe.tsx` become useful
|
||||
(per-id commit timings) instead of inert.
|
||||
@@ -0,0 +1,22 @@
|
||||
// rebuild-native.mjs
|
||||
import { rebuild } from '@electron/rebuild'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isMain } from './utils.mjs'
|
||||
import packageJson from '../package.json' with { type: 'json' }
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
export async function rebuildNodePty({ arch = process.arch } = {}) {
|
||||
await rebuild({
|
||||
buildPath: projectRoot, // where node_modules lives
|
||||
electronVersion: packageJson.devDependencies.electron.replace('^', ''),
|
||||
arch,
|
||||
onlyModules: ['node-pty'],
|
||||
force: true
|
||||
})
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const [arch] = process.argv.slice(2)
|
||||
await rebuildNodePty({ arch })
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Reload the renderer via CDP so it picks up the latest from Vite.
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (method, params = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method, params }))
|
||||
})
|
||||
await send('Page.enable')
|
||||
await send('Page.reload', { ignoreCache: true })
|
||||
console.log('reload requested')
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
ws.close()
|
||||
@@ -0,0 +1,36 @@
|
||||
// Hard reload the Electron renderer over CDP. Vite-no-HMR mode means edits
|
||||
// don't auto-apply — call this after editing source.
|
||||
const targets = await (await fetch('http://127.0.0.1:9222/json')).json()
|
||||
const t = targets.find((t) => t.url.includes('5174'))
|
||||
if (!t) {
|
||||
console.error('renderer not found')
|
||||
process.exit(1)
|
||||
}
|
||||
const ws = new WebSocket(t.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise((r) => ws.addEventListener('open', r))
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((res) => {
|
||||
const i = ++id
|
||||
pending.set(i, res)
|
||||
ws.send(JSON.stringify({ id: i, method, params }))
|
||||
})
|
||||
|
||||
await send('Page.reload', { ignoreCache: true })
|
||||
console.log('reload sent')
|
||||
// Wait for new doc.
|
||||
await new Promise((r) => setTimeout(r, 2500))
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: 'JSON.stringify({ hasProbe: !!window.__PERF_PROBE__, composer: !!document.querySelector("[contenteditable=true]"), url: location.hash })',
|
||||
returnByValue: true,
|
||||
})
|
||||
console.log(r.result.result.value)
|
||||
ws.close()
|
||||
@@ -0,0 +1,67 @@
|
||||
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
|
||||
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
|
||||
// npm workspace hoisting is non-deterministic — require.resolve finds electron
|
||||
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
|
||||
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
|
||||
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
function electronDistDir() {
|
||||
try {
|
||||
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function distBinary(dist) {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(dist, "electron.exe")
|
||||
}
|
||||
return path.join(dist, "electron")
|
||||
}
|
||||
|
||||
function electronBuilderCli() {
|
||||
const pkgJson = require.resolve("electron-builder/package.json")
|
||||
const bin = require(pkgJson).bin
|
||||
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
|
||||
return path.join(path.dirname(pkgJson), rel)
|
||||
}
|
||||
|
||||
const dist = electronDistDir()
|
||||
// Local `hermes desktop` builds only ever package (--dir or dist), never
|
||||
// publish a GitHub release — no CI workflow drives this script. But the npm
|
||||
// lifecycle env sets CI=1 (so esbuild's postinstall doesn't try interactive
|
||||
// animations), and electron-builder treats CI=1 as a signal to implicitly
|
||||
// resolve a publish target. That resolution reads <projectDir>/.git/config
|
||||
// directly — projectDir here is apps/desktop, which has no .git of its own
|
||||
// (only the repo root does) and no "repository" field in its package.json —
|
||||
// so it fails with "Cannot detect repository by .git/config". Pin publish to
|
||||
// "never" so electron-builder skips that lookup entirely.
|
||||
const args = ["--publish", "never"]
|
||||
if (dist && fs.existsSync(distBinary(dist))) {
|
||||
args.push(`-c.electronDist=${dist}`)
|
||||
} else {
|
||||
console.warn(
|
||||
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
|
||||
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
|
||||
)
|
||||
}
|
||||
args.push(...process.argv.slice(2))
|
||||
|
||||
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
|
||||
stdio: "inherit",
|
||||
})
|
||||
if (result.error) {
|
||||
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(result.status == null ? 1 : result.status)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, writeFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import {
|
||||
ReproductionError,
|
||||
classify,
|
||||
pairedSoftSignal,
|
||||
resultForError,
|
||||
validateSummary,
|
||||
waitFor,
|
||||
waitForPredicate,
|
||||
waitForResponsive,
|
||||
withTemporarySandbox,
|
||||
withTimeout
|
||||
} from './run-short-session-hang-repro.mjs'
|
||||
|
||||
const result = (outcome, latency = 10) => ({
|
||||
hardFailure: outcome !== 'not-reproduced',
|
||||
maxGapMs: latency,
|
||||
maxOperationMs: latency,
|
||||
outcome
|
||||
})
|
||||
|
||||
test('separates harness errors from reproduction timeouts', () => {
|
||||
const harness = resultForError(new Error('fixture mismatch'))
|
||||
const reproduced = resultForError(new ReproductionError('renderer operation exceeded 5000ms'))
|
||||
|
||||
assert.equal(harness.outcome, 'harness-error')
|
||||
assert.equal(harness.maxGapMs, null)
|
||||
assert.equal(harness.maxOperationMs, null)
|
||||
assert.equal(reproduced.outcome, 'reproduced')
|
||||
})
|
||||
|
||||
test('preserves timeout semantics through the actual nested helpers', async () => {
|
||||
const immediate = await withTimeout(Promise.reject(new Error('precondition')), 30, 'outer', ReproductionError).catch(
|
||||
error => error
|
||||
)
|
||||
assert.equal(resultForError(immediate).outcome, 'harness-error')
|
||||
|
||||
const stalledCdp = { eval: async () => false }
|
||||
const inner = await withTimeout(
|
||||
waitFor(stalledCdp, 'false', 10, 'inner product operation', ReproductionError),
|
||||
50,
|
||||
'outer product operation',
|
||||
ReproductionError
|
||||
).catch(error => error)
|
||||
assert.ok(inner instanceof ReproductionError)
|
||||
assert.match(inner.message, /inner product operation/)
|
||||
|
||||
const outer = await withTimeout(
|
||||
waitFor(stalledCdp, 'false', 50, 'inner product operation', ReproductionError),
|
||||
10,
|
||||
'outer product operation',
|
||||
ReproductionError
|
||||
).catch(error => error)
|
||||
assert.ok(outer instanceof ReproductionError)
|
||||
assert.match(outer.message, /outer product operation/)
|
||||
|
||||
const nearDeadline = await withTimeout(
|
||||
new Promise(resolve => setTimeout(() => resolve('responsive'), 20)),
|
||||
50,
|
||||
'responsive operation',
|
||||
ReproductionError
|
||||
)
|
||||
assert.equal(nearDeadline, 'responsive')
|
||||
})
|
||||
|
||||
test('distinguishes a responsive false condition from a stalled renderer evaluation', { timeout: 2_000 }, async () => {
|
||||
let transientEvaluations = 0
|
||||
await waitForResponsive(
|
||||
{
|
||||
eval: async () => {
|
||||
transientEvaluations += 1
|
||||
|
||||
if (transientEvaluations === 1) {
|
||||
throw new Error('execution context was destroyed')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
'true',
|
||||
500,
|
||||
'transient condition',
|
||||
50
|
||||
)
|
||||
assert.equal(transientEvaluations, 2)
|
||||
|
||||
await assert.rejects(
|
||||
waitForResponsive({ eval: async () => false }, 'false', 10, 'responsive condition', 1_000),
|
||||
error => !(error instanceof ReproductionError) && /renderer remained responsive/.test(error.message)
|
||||
)
|
||||
await assert.rejects(
|
||||
waitForResponsive({ eval: () => new Promise(() => {}) }, 'false', 50, 'stalled condition', 10),
|
||||
ReproductionError
|
||||
)
|
||||
await assert.rejects(
|
||||
waitForPredicate(() => false, 10, 'provider request'),
|
||||
/provider request/
|
||||
)
|
||||
})
|
||||
|
||||
test('invalidates a target when warmup or measured runs have harness errors', () => {
|
||||
const passing = Array.from({ length: 5 }, () => result('not-reproduced'))
|
||||
|
||||
assert.equal(classify(passing, result('harness-error')).classification, 'invalid')
|
||||
assert.equal(
|
||||
classify([result('harness-error'), ...passing.slice(1)], result('not-reproduced')).classification,
|
||||
'invalid'
|
||||
)
|
||||
assert.equal(classify(passing, result('not-reproduced')).classification, 'not-reproduced')
|
||||
assert.equal(
|
||||
classify(
|
||||
[result('reproduced'), result('reproduced'), result('reproduced'), result('reproduced'), passing[0]],
|
||||
passing[0]
|
||||
).classification,
|
||||
'reproduced'
|
||||
)
|
||||
})
|
||||
|
||||
test('suppresses soft-signal comparisons when a run is invalid or reproduced', () => {
|
||||
const passing = Array.from({ length: 5 }, () => result('not-reproduced', 10))
|
||||
|
||||
assert.equal(pairedSoftSignal([], passing).reason, 'insufficient-runs')
|
||||
assert.equal(pairedSoftSignal(passing, []).reason, 'insufficient-runs')
|
||||
assert.equal(pairedSoftSignal(passing, passing).reason, undefined)
|
||||
assert.equal(pairedSoftSignal([result('harness-error'), ...passing.slice(1)], passing).reason, 'hard-or-invalid-run')
|
||||
assert.equal(pairedSoftSignal([result('reproduced'), ...passing.slice(1)], passing).reason, 'hard-or-invalid-run')
|
||||
})
|
||||
|
||||
test('rejects contradictory summary semantics', () => {
|
||||
const passing = Array.from({ length: 5 }, () => result('not-reproduced'))
|
||||
const classification = classify(passing, result('not-reproduced'))
|
||||
const target = { ...classification, runs: passing, warmup: result('not-reproduced') }
|
||||
const summary = { baseline: target, candidate: target, invalid: false }
|
||||
|
||||
assert.doesNotThrow(() => validateSummary(summary, 5))
|
||||
assert.throws(
|
||||
() => validateSummary({ ...summary, baseline: { ...target, classification: 'reproduced' } }, 5),
|
||||
/inconsistent baseline summary classification/
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
validateSummary(
|
||||
{
|
||||
...summary,
|
||||
baseline: { ...target, runs: [{ ...passing[0], hardFailure: true }, ...passing.slice(1)] }
|
||||
},
|
||||
5
|
||||
),
|
||||
/inconsistent baseline run outcome and hardFailure/
|
||||
)
|
||||
})
|
||||
|
||||
test('removes the exact temporary sandbox when the run body throws', async () => {
|
||||
let sandbox
|
||||
|
||||
await assert.rejects(
|
||||
withTemporarySandbox('cleanup-test', path => {
|
||||
sandbox = path
|
||||
writeFileSync(join(path, 'diagnostic.txt'), 'temporary')
|
||||
throw new Error('teardown report failed')
|
||||
}),
|
||||
/teardown report failed/
|
||||
)
|
||||
|
||||
assert.ok(sandbox)
|
||||
assert.equal(existsSync(sandbox), false)
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
// set-exe-identity.mjs — stamp the Hermes icon + version metadata onto the
|
||||
// built Hermes.exe using rcedit, completely decoupled from electron-builder's
|
||||
// signing path.
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
// ---------------
|
||||
// apps/desktop/package.json sets build.win.signAndEditExecutable=false. That
|
||||
// flag is load-bearing: turning electron-builder's own exe-editing ON also
|
||||
// re-enables its signtool step, which fetches winCodeSign-2.6.0.7z, whose
|
||||
// macOS symlinks crash 7-Zip on non-admin Windows (no Developer Mode = no
|
||||
// SeCreateSymbolicLinkPrivilege). That is an unfixable dead end — we do NOT
|
||||
// try to extract winCodeSign.
|
||||
//
|
||||
// The cost of disabling signAndEditExecutable is that electron-builder also
|
||||
// skips rcedit, so the unpacked Hermes.exe keeps the stock Electron icon and
|
||||
// "Electron" taskbar name. This script restores the icon + identity by calling
|
||||
// rcedit DIRECTLY. rcedit is a pure PE resource editor: no signing, no certs,
|
||||
// no winCodeSign, no symlinks.
|
||||
//
|
||||
// HOW IT RUNS
|
||||
// -----------
|
||||
// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.mjs),
|
||||
// so EVERY packed build — first install, `hermes desktop`, the installer's
|
||||
// --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from
|
||||
// one place. Previously this stamp lived only in install.ps1, so the update
|
||||
// path (which rebuilds via `hermes desktop --build-only`, never install.ps1)
|
||||
// shipped a stock "Electron" exe. Keeping it in afterPack closes that gap.
|
||||
//
|
||||
// Also runnable standalone for ad-hoc re-stamping:
|
||||
// node scripts/set-exe-identity.mjs <path-to-Hermes.exe>
|
||||
//
|
||||
// Exits 0 on success, non-zero on failure when run as a CLI. As a hook,
|
||||
// stampExeIdentity() resolves on success and rejects on failure; the caller
|
||||
// (after-pack.mjs) swallows the rejection so a stamp failure never fails an
|
||||
// otherwise-good build (worst case: stock icon, not a broken app).
|
||||
|
||||
import { resolve, join } from 'node:path'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
import { rcedit } from 'rcedit'
|
||||
|
||||
import { isMain } from './utils.mjs'
|
||||
|
||||
// Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on
|
||||
// failure. `desktopRoot` defaults to this script's package root so the icon and
|
||||
// the rcedit dependency resolve regardless of cwd.
|
||||
async function stampExeIdentity(exe, desktopRoot = resolve(import.meta.dirname, '..')) {
|
||||
if (!exe || !existsSync(exe)) {
|
||||
throw new Error(`target exe not found: ${exe}`)
|
||||
}
|
||||
|
||||
// Icon lives at apps/desktop/assets/icon.ico
|
||||
const icon = join(desktopRoot, 'assets', 'icon.ico')
|
||||
if (!existsSync(icon)) {
|
||||
throw new Error(`icon not found: ${icon}`)
|
||||
}
|
||||
|
||||
console.log(`[set-exe-identity] stamping ${exe}`)
|
||||
console.log(`[set-exe-identity] icon: ${icon}`)
|
||||
const manifest = JSON.parse(readFileSync(join(desktopRoot, 'package.json'), 'utf8'))
|
||||
|
||||
await rcedit(exe, {
|
||||
icon,
|
||||
'file-version': `${manifest.version.split('-')[0]}.0`,
|
||||
'product-version': `${manifest.version.split('-')[0]}.0`,
|
||||
'version-string': {
|
||||
ProductName: manifest.productName,
|
||||
FileDescription: manifest.productName,
|
||||
CompanyName: 'AITURK / TurkServis',
|
||||
LegalCopyright: manifest.build.copyright
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[set-exe-identity] done — AITURK icon + identity stamped')
|
||||
}
|
||||
|
||||
export { stampExeIdentity }
|
||||
|
||||
// CLI entry point: `node scripts/set-exe-identity.mjs <exe>`.
|
||||
if (isMain(import.meta.url)) {
|
||||
const exe = process.argv[2]
|
||||
if (!exe) {
|
||||
console.error('[set-exe-identity] usage: set-exe-identity.mjs <path-to-exe>')
|
||||
process.exit(2)
|
||||
}
|
||||
stampExeIdentity(exe).catch(err => {
|
||||
console.error(`[set-exe-identity] ${err.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env node
|
||||
// stage-native-deps.mjs — stages node-pty's native runtime dependencies
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/stage-native-deps.mjs # host platform/arch
|
||||
// node scripts/stage-native-deps.mjs win32 arm64 # explicit target
|
||||
//
|
||||
// Also exported as `stageNodePty({ platform, arch })` for use from
|
||||
// before-pack.mjs, where electron-builder gives you the real per-target
|
||||
// platform/arch during multi-arch builds.
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, resolve, join } from 'node:path'
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { isMain } from './utils.mjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = resolve(here, '..')
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
function makeExecutable(filePath) {
|
||||
chmodSync(filePath, 0o755)
|
||||
}
|
||||
|
||||
function patchUnixTerminalAsarPaths(destRoot) {
|
||||
const filePath = join(destRoot, 'lib', 'unixTerminal.js')
|
||||
if (!existsSync(filePath)) return
|
||||
|
||||
const source = readFileSync(filePath, 'utf8')
|
||||
const patched = source
|
||||
.replace(
|
||||
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"helperPath = helperPath.replace(/app\\.asar(?!\\.unpacked)/, 'app.asar.unpacked');"
|
||||
)
|
||||
.replace(
|
||||
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
"helperPath = helperPath.replace(/node_modules\\.asar(?!\\.unpacked)/, 'node_modules.asar.unpacked');"
|
||||
)
|
||||
|
||||
if (patched !== source) {
|
||||
writeFileSync(filePath, patched)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate node-pty's package root via real module resolution, so this
|
||||
* works whether it's hoisted to a workspace root or local to this app.
|
||||
*/
|
||||
function resolveNodePtyRoot() {
|
||||
const pkgJsonPath = require.resolve('node-pty/package.json', {
|
||||
paths: [projectRoot]
|
||||
})
|
||||
return dirname(pkgJsonPath)
|
||||
}
|
||||
|
||||
function copyGlobByExt(srcDir, destDir, extensions) {
|
||||
if (!existsSync(srcDir)) return
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
copyGlobByExt(join(srcDir, entry.name), join(destDir, entry.name), extensions)
|
||||
continue
|
||||
}
|
||||
if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
cpSync(join(srcDir, entry.name), join(destDir, entry.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the locally-compiled build/Release output (used when no prebuild
|
||||
* was available and node-pty was built from source for the host machine).
|
||||
*
|
||||
* Filters by name/pattern rather than extension only: macOS builds a
|
||||
* separate `spawn-helper` executable (no file extension) that
|
||||
* lib/unixTerminal.js requires at a fixed relative path. Filtering this
|
||||
* directory by ['.node'] silently drops it — the package then looks
|
||||
* fine, ships fine, and crashes the first time a terminal is spawned.
|
||||
* Directories are copied wholesale to also cover any nested native
|
||||
* payload (e.g. a conpty/ subfolder some build layouts produce).
|
||||
*/
|
||||
function copyBuildRelease(srcDir, destDir) {
|
||||
if (!existsSync(srcDir)) return
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
cpSync(join(srcDir, entry.name), join(destDir, entry.name), { recursive: true })
|
||||
continue
|
||||
}
|
||||
if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) {
|
||||
const destFile = join(destDir, entry.name)
|
||||
cpSync(join(srcDir, entry.name), destFile)
|
||||
if (entry.name === 'spawn-helper') {
|
||||
makeExecutable(destFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── binary classification ───────────────────────────────────────────
|
||||
//
|
||||
// .node files are shared libraries in the target platform's native binary
|
||||
// format. By reading the first few bytes (magic) we can determine which
|
||||
// platform a given .node was compiled for, without shelling out to `file`.
|
||||
//
|
||||
// ELF (\x7fELF) → linux
|
||||
// Mach-O 32-bit BE (feedface) → darwin
|
||||
// Mach-O 64-bit BE (feedfacf) → darwin
|
||||
// Mach-O 32-bit LE (cefaedfe — CIGAM) → darwin
|
||||
// Mach-O 64-bit LE (cffaedfe — CIGAM_64) → darwin
|
||||
// Fat/Universal BE (cafebabe) → darwin
|
||||
// Fat/Universal LE (bebafeca — FAT_CIGAM) → darwin
|
||||
// PE (MZ DOS header) → win32
|
||||
//
|
||||
// Mach-O and Fat binaries are stored on disk in the host's native byte
|
||||
// order. On x64/arm64 Darwin (every Apple Silicon + every Intel Mac that
|
||||
// ships node-pty prebuilds) that is little-endian, so the on-disk magic is
|
||||
// the CIGAM byte-swapped form, NOT the big-endian MH_MAGIC form. Checking
|
||||
// only the BE constants misclassifies every real Darwin prebuild as unknown.
|
||||
//
|
||||
// Exported for unit testing.
|
||||
|
||||
/**
|
||||
* Classify a native binary's target platform from its magic bytes.
|
||||
* Returns `'linux'`, `'darwin'`, `'win32'`, or `null` if unrecognized
|
||||
* or the file cannot be read.
|
||||
*/
|
||||
export function classifyNativeBinary(filePath) {
|
||||
let buf
|
||||
try {
|
||||
buf = readFileSync(filePath, { start: 0, end: 63 }) // first 64 bytes
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (buf.length < 4) return null
|
||||
|
||||
// ELF: \x7f E L F
|
||||
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
|
||||
return 'linux'
|
||||
}
|
||||
// Mach-O 32-bit (big-endian / MH_MAGIC): feedface
|
||||
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Mach-O 64-bit (big-endian / MH_MAGIC_64): feedfacf
|
||||
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Mach-O 32-bit (little-endian / MH_CIGAM): cefaedfe
|
||||
if (buf[0] === 0xce && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Mach-O 64-bit (little-endian / MH_CIGAM_64): cffaedfe
|
||||
if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Fat/Universal binary (big-endian / FAT_MAGIC): cafebabe
|
||||
if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) {
|
||||
return 'darwin'
|
||||
}
|
||||
// Fat/Universal binary (little-endian / FAT_CIGAM): bebafeca
|
||||
if (buf[0] === 0xbe && buf[1] === 0xba && buf[2] === 0xfe && buf[3] === 0xca) {
|
||||
return 'darwin'
|
||||
}
|
||||
// PE: MZ DOS header
|
||||
if (buf[0] === 0x4d && buf[1] === 0x5a) {
|
||||
return 'win32'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the staged destination tree for .node files and verify each one's
|
||||
* binary platform matches the requested target. Throws on any mismatch.
|
||||
*
|
||||
* This is the fail-closed safety net: even if a prebuild or build/Release
|
||||
* somehow slipped through with the wrong platform, this catches it before
|
||||
* the package ships a broken native binary to users.
|
||||
*/
|
||||
function validateStagedBinaries(destRoot, targetPlatform) {
|
||||
const mismatches = []
|
||||
function scan(dir, relPrefix) {
|
||||
if (!existsSync(dir)) return
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
scan(join(dir, entry.name), `${relPrefix}${entry.name}/`)
|
||||
continue
|
||||
}
|
||||
if (!entry.name.endsWith('.node')) continue
|
||||
const fullPath = join(dir, entry.name)
|
||||
const classified = classifyNativeBinary(fullPath)
|
||||
if (classified !== targetPlatform) {
|
||||
mismatches.push({ file: `${relPrefix}${entry.name}`, classified, expected: targetPlatform })
|
||||
}
|
||||
}
|
||||
}
|
||||
scan(join(destRoot, 'prebuilds'), 'prebuilds/')
|
||||
scan(join(destRoot, 'build', 'Release'), 'build/Release/')
|
||||
if (mismatches.length > 0) {
|
||||
throw new Error(
|
||||
`[stage-native-deps] native binary platform mismatch (target=${targetPlatform}):\n` +
|
||||
mismatches
|
||||
.map((m) => ` ${m.file}: expected ${m.expected}, got ${m.classified ?? 'unknown'}`)
|
||||
.join('\n') +
|
||||
`\nRefusing to stage a binary compiled for the wrong platform.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage node-pty's native runtime dependencies into `destRoot`.
|
||||
*
|
||||
* Exported separately from `stageNodePty` so tests can supply a fake
|
||||
* node-pty source tree without going through real module resolution.
|
||||
*
|
||||
* Strategy (fail-closed):
|
||||
*
|
||||
* 1. Copy the matching prebuild (`prebuilds/<platform>-<arch>/`) if present.
|
||||
* 2. Copy `build/Release/` **only when the target matches the host** —
|
||||
* build/Release contains a binary compiled for the host's platform/arch,
|
||||
* so staging it for a different target ships a broken app.
|
||||
* 3. If no native binary was staged:
|
||||
* - Same platform as host, different arch → run `electron-rebuild --arch`.
|
||||
* - Different platform from host → throw (cannot cross-compile native
|
||||
* modules; build on the target platform or provide a prebuild).
|
||||
* 4. Validate every staged `.node` file's binary platform matches the target.
|
||||
*/
|
||||
export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platform, arch = process.arch } = {}) {
|
||||
const hostMatch = platform === process.platform && arch === process.arch
|
||||
|
||||
rmSync(destRoot, { recursive: true, force: true })
|
||||
mkdirSync(destRoot, { recursive: true })
|
||||
|
||||
// package.json — needed so `require('node-pty')` resolves the package
|
||||
// (reads "main") rather than treating it as a directory with no entry.
|
||||
cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json'))
|
||||
|
||||
// lib/**/*.js — the JS surface node-pty's `main` points into.
|
||||
copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js'])
|
||||
patchUnixTerminalAsarPaths(destRoot)
|
||||
|
||||
// prebuilds/<platform>-<arch>/* — the prebuild-install payload for the
|
||||
// *target* we're packaging, not necessarily the host running this script.
|
||||
// Explicit extensions only, to skip the ~25MB of Windows .pdb symbols
|
||||
// prebuild-install bundles alongside the .node/.dll.
|
||||
const prebuildDir = join(srcRoot, 'prebuilds', `${platform}-${arch}`)
|
||||
if (existsSync(prebuildDir)) {
|
||||
const destPrebuild = join(destRoot, 'prebuilds', `${platform}-${arch}`)
|
||||
mkdirSync(destPrebuild, { recursive: true })
|
||||
for (const entry of readdirSync(prebuildDir, { withFileTypes: true })) {
|
||||
if (entry.name === 'conpty' && entry.isDirectory()) {
|
||||
cpSync(join(prebuildDir, 'conpty'), join(destPrebuild, 'conpty'), { recursive: true })
|
||||
continue
|
||||
}
|
||||
if (entry.isFile() && /\.(node|dll|exe)$/.test(entry.name)) {
|
||||
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
|
||||
continue
|
||||
}
|
||||
if (entry.name === 'spawn-helper') {
|
||||
const destFile = join(destPrebuild, entry.name)
|
||||
cpSync(join(prebuildDir, entry.name), destFile)
|
||||
makeExecutable(destFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// build/Release/* — present when node-pty was compiled locally
|
||||
// (e.g. no prebuild available for this Electron ABI/platform combo).
|
||||
// Only stage this when the target matches the host, because
|
||||
// build/Release contains a binary compiled for the *host's* platform
|
||||
// and architecture. Staging a host binary for a different target (e.g.
|
||||
// a macOS Mach-O .node staged for a linux-arm64 target) ships a broken
|
||||
// app that crashes the first time a terminal is spawned.
|
||||
if (hostMatch) {
|
||||
const buildReleaseDir = join(srcRoot, 'build/Release')
|
||||
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
|
||||
}
|
||||
|
||||
// Check whether a native binary for this target was staged.
|
||||
const stagedDirs = [
|
||||
join(destRoot, 'prebuilds', `${platform}-${arch}`),
|
||||
join(destRoot, 'build/Release')
|
||||
]
|
||||
const hasNativeBinary = stagedDirs.some((dir) => {
|
||||
if (!existsSync(dir)) return false
|
||||
return readdirSync(dir, { recursive: true }).some((name) => String(name).endsWith('.node'))
|
||||
})
|
||||
|
||||
if (!hasNativeBinary) {
|
||||
if (platform !== process.platform) {
|
||||
throw new Error(
|
||||
`[stage-native-deps] no prebuilt binary for ${platform}-${arch} and ` +
|
||||
`cannot cross-compile native modules from ${process.platform}-${process.arch}. ` +
|
||||
`Build on the target platform or provide a prebuild.`
|
||||
)
|
||||
}
|
||||
// Same platform, possibly different arch — rebuild from source with
|
||||
// the target architecture so electron-rebuild produces the correct
|
||||
// binary rather than defaulting to the host's arch.
|
||||
console.log(
|
||||
`[stage-native-deps] no native binary for ${platform}-${arch}; ` +
|
||||
`running electron-rebuild (target arch: ${arch})...`
|
||||
)
|
||||
const rebuildArgs = [
|
||||
'../../node_modules/.bin/electron-rebuild',
|
||||
'-f',
|
||||
'-w',
|
||||
'node-pty',
|
||||
'--arch',
|
||||
arch
|
||||
]
|
||||
const result = spawnSync(process.execPath, rebuildArgs, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`electron-rebuild failed for ${platform}-${arch} (exit ${result.status}). ` +
|
||||
`Cannot stage node-pty without a native binary.`
|
||||
)
|
||||
}
|
||||
// Re-copy build/Release after electron-rebuild populated it.
|
||||
const buildReleaseDir = join(srcRoot, 'build/Release')
|
||||
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
|
||||
}
|
||||
|
||||
// Validate every staged .node binary matches the target platform.
|
||||
validateStagedBinaries(destRoot, platform)
|
||||
|
||||
console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`)
|
||||
return destRoot
|
||||
}
|
||||
|
||||
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
|
||||
const srcRoot = resolveNodePtyRoot()
|
||||
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
|
||||
return stageNodePtyInto(srcRoot, destRoot, { platform, arch })
|
||||
}
|
||||
|
||||
// ─── get-windows (read_window_below tool) ────────────────────────────
|
||||
//
|
||||
// Staged like node-pty: external to the esbuild bundle, resolved at runtime
|
||||
// from dist/node_modules (which asarUnpack ships unpacked via `dist/**`).
|
||||
//
|
||||
// The published package's lib/windows.js statically imports
|
||||
// @mapbox/node-pre-gyp — and index.js statically imports lib/windows.js on
|
||||
// EVERY platform — so shipping it verbatim would drag node-pre-gyp's whole
|
||||
// dependency tree into the package. pre-gyp is only used to *locate* the
|
||||
// prebuilt .node we stage ourselves, so the staged copy replaces
|
||||
// lib/windows.js with a resolver that requires the staged binding directly
|
||||
// (and fails soft to no-op stubs, matching upstream's missing-binding
|
||||
// behavior).
|
||||
|
||||
const STAGED_WINDOWS_JS = `// Rewritten by stage-native-deps.mjs: resolves the staged prebuilt binding
|
||||
// directly instead of through the pre-gyp locator (see stageGetWindowsInto).
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createRequire} from 'node:module';
|
||||
|
||||
const getAddon = () => {
|
||||
\tconst require = createRequire(import.meta.url);
|
||||
\tconst bindingRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), 'binding');
|
||||
|
||||
\ttry {
|
||||
\t\tfor (const dir of fs.readdirSync(bindingRoot)) {
|
||||
\t\t\tconst bindingPath = path.join(bindingRoot, dir, 'node-get-windows.node');
|
||||
\t\t\tif (fs.existsSync(bindingPath)) {
|
||||
\t\t\t\treturn require(bindingPath);
|
||||
\t\t\t}
|
||||
\t\t}
|
||||
\t} catch {}
|
||||
|
||||
\treturn {
|
||||
\t\tgetActiveWindow() {},
|
||||
\t\tgetOpenWindows() {},
|
||||
\t};
|
||||
};
|
||||
|
||||
export async function activeWindow() {
|
||||
\treturn getAddon().getActiveWindow();
|
||||
}
|
||||
|
||||
export function activeWindowSync() {
|
||||
\treturn getAddon().getActiveWindow();
|
||||
}
|
||||
|
||||
export function openWindows() {
|
||||
\treturn getAddon().getOpenWindows();
|
||||
}
|
||||
|
||||
export function openWindowsSync() {
|
||||
\treturn getAddon().getOpenWindows();
|
||||
}
|
||||
`
|
||||
|
||||
function resolveGetWindowsRoot() {
|
||||
// get-windows is an optionalDependency (its node-pre-gyp install script has
|
||||
// no Linux or Windows ARM64 prebuilt and its node-gyp fallback may fail, so
|
||||
// `npm ci` can skip it entirely on those targets). Return null when it is
|
||||
// absent; the caller decides whether that is fatal per platform and arch.
|
||||
try {
|
||||
// get-windows' exports map doesn't expose ./package.json; resolve the entry
|
||||
// (index.js sits at the package root) and take its directory.
|
||||
const entryPath = require.resolve('get-windows', {
|
||||
paths: [projectRoot]
|
||||
})
|
||||
return dirname(entryPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage get-windows into `destRoot` for `platform`.
|
||||
*
|
||||
* Per-platform native payload: macOS ships the `main` Swift helper binary
|
||||
* (universal, present in every published tarball), Windows the node-pre-gyp
|
||||
* prebuilt under lib/binding (downloaded by the package's install script on
|
||||
* a Windows host — cross-platform packs already can't happen, see
|
||||
* stageNodePtyInto), Linux nothing (it shells out to xprop at runtime).
|
||||
*/
|
||||
const GET_WINDOWS_VERSION = '9.3.0'
|
||||
|
||||
export function stageGetWindowsInto(
|
||||
srcRoot,
|
||||
destRoot,
|
||||
{ platform = process.platform, arch = process.arch, install } = {}
|
||||
) {
|
||||
// The STAGED_WINDOWS_JS rewrite mirrors this exact version's export surface.
|
||||
// A version bump must fail the build here until the rewrite is re-verified —
|
||||
// otherwise it ships stale and fails soft as a generic "unavailable".
|
||||
const srcVersion = JSON.parse(readFileSync(join(srcRoot, 'package.json'), 'utf8')).version
|
||||
if (srcVersion !== GET_WINDOWS_VERSION) {
|
||||
throw new Error(
|
||||
`[stage-native-deps] get-windows is ${srcVersion} but the staged lib/windows.js ` +
|
||||
`rewrite was verified against ${GET_WINDOWS_VERSION}. Re-verify the rewrite ` +
|
||||
`(STAGED_WINDOWS_JS) against the new version, then update GET_WINDOWS_VERSION.`
|
||||
)
|
||||
}
|
||||
|
||||
rmSync(destRoot, { recursive: true, force: true })
|
||||
mkdirSync(destRoot, { recursive: true })
|
||||
|
||||
cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json'))
|
||||
cpSync(join(srcRoot, 'index.js'), join(destRoot, 'index.js'))
|
||||
|
||||
// lib/*.js only — NOT copyGlobByExt, which recurses into lib/binding and
|
||||
// stages empty dirs for every prebuilt slot (including the darwin one the
|
||||
// tarball bundles on all platforms). Bindings are staged explicitly below.
|
||||
mkdirSync(join(destRoot, 'lib'), { recursive: true })
|
||||
for (const entry of readdirSync(join(srcRoot, 'lib'), { withFileTypes: true })) {
|
||||
if (entry.isFile() && entry.name.endsWith('.js')) {
|
||||
cpSync(join(srcRoot, 'lib', entry.name), join(destRoot, 'lib', entry.name))
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(join(destRoot, 'lib', 'windows.js'), STAGED_WINDOWS_JS)
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const helper = join(srcRoot, 'main')
|
||||
if (!existsSync(helper)) {
|
||||
throw new Error('[stage-native-deps] get-windows is missing its macOS helper binary (main)')
|
||||
}
|
||||
cpSync(helper, join(destRoot, 'main'))
|
||||
makeExecutable(join(destRoot, 'main'))
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// The published tarball bundles a darwin binding dir on EVERY platform
|
||||
// (its `files` includes lib/), so a Windows host's lib/binding holds both
|
||||
// that and the win32 dir node-pre-gyp downloaded. Stage only dirs naming
|
||||
// the target platform; the classify gate below still catches a dir that
|
||||
// claims win32 but holds a foreign binary.
|
||||
const bindingRoot = join(srcRoot, 'lib', 'binding')
|
||||
const scanBindingDirs = () =>
|
||||
existsSync(bindingRoot)
|
||||
? readdirSync(bindingRoot).filter(
|
||||
(dir) =>
|
||||
dir.includes(`-${platform}-`) &&
|
||||
dir.endsWith(`-${arch}`) &&
|
||||
existsSync(join(bindingRoot, dir, 'node-get-windows.node'))
|
||||
)
|
||||
: []
|
||||
let bindingDirs = scanBindingDirs()
|
||||
let installAttempted = false
|
||||
if (bindingDirs.length === 0 && arch === 'arm64') {
|
||||
// get-windows 9.3.0 publishes win32 prebuilds for ia32/x64 only.
|
||||
// The staged windows.js deliberately fails soft when binding/ is absent,
|
||||
// so preserve the desktop build and disable only window enumeration.
|
||||
console.warn(
|
||||
'[stage-native-deps] get-windows has no win32-arm64 prebuilt binding; ' +
|
||||
'staging the fail-soft JS surface without native window enumeration.'
|
||||
)
|
||||
} else if (bindingDirs.length === 0 && typeof install === 'function') {
|
||||
// A plain `npm install` won't re-run an install script for a package
|
||||
// that is already on disk, so every checkout that installed while
|
||||
// get-windows was missing from allowScripts stays bricked even after
|
||||
// the allowlist is fixed. Invoke node-pre-gyp directly: npm treats this
|
||||
// optional dependency's failed lifecycle as non-fatal and can report a
|
||||
// successful rebuild without producing the Windows binding.
|
||||
console.log(
|
||||
'[stage-native-deps] get-windows has no win32 binding; running its native installer...'
|
||||
)
|
||||
installAttempted = true
|
||||
install()
|
||||
bindingDirs = scanBindingDirs()
|
||||
}
|
||||
if (bindingDirs.length === 0 && arch !== 'arm64') {
|
||||
const reason = installAttempted
|
||||
? `native installer completed without producing a win32-${arch} binding under lib/binding`
|
||||
: `has no win32-${arch} prebuilt binding under lib/binding`
|
||||
throw new Error(`[stage-native-deps] get-windows ${reason}`)
|
||||
}
|
||||
for (const dir of bindingDirs) {
|
||||
const dest = join(destRoot, 'lib', 'binding', dir)
|
||||
mkdirSync(dest, { recursive: true })
|
||||
const destFile = join(dest, 'node-get-windows.node')
|
||||
cpSync(join(bindingRoot, dir, 'node-get-windows.node'), destFile)
|
||||
const classified = classifyNativeBinary(destFile)
|
||||
if (classified !== platform) {
|
||||
throw new Error(
|
||||
`[stage-native-deps] get-windows binding ${dir}/node-get-windows.node: ` +
|
||||
`expected ${platform}, got ${classified ?? 'unknown'}. ` +
|
||||
'Refusing to stage a binary compiled for the wrong platform.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[stage-native-deps] staged get-windows (${platform}) -> ${destRoot}`)
|
||||
return destRoot
|
||||
}
|
||||
|
||||
export function installGetWindowsNativeBinding(
|
||||
srcRoot,
|
||||
{ resolveInstaller, spawn = spawnSync } = {}
|
||||
) {
|
||||
let installerPath
|
||||
try {
|
||||
const resolveNodePreGyp =
|
||||
resolveInstaller ??
|
||||
(() =>
|
||||
require.resolve('@mapbox/node-pre-gyp/bin/node-pre-gyp', {
|
||||
paths: [srcRoot]
|
||||
}))
|
||||
installerPath = resolveNodePreGyp()
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`[stage-native-deps] cannot resolve get-windows native installer: ${detail}`)
|
||||
}
|
||||
|
||||
const result = spawn(process.execPath, [installerPath, 'install', '--fallback-to-build'], {
|
||||
cwd: srcRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`[stage-native-deps] get-windows native installer could not start: ${result.error.message}`
|
||||
)
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`[stage-native-deps] get-windows native installer exited with ${result.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function stageGetWindows(
|
||||
{
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
resolveRoot = resolveGetWindowsRoot
|
||||
} = {}
|
||||
) {
|
||||
const srcRoot = resolveRoot()
|
||||
const destRoot = resolve(projectRoot, 'dist/node_modules/get-windows')
|
||||
|
||||
if (!srcRoot) {
|
||||
// npm may omit an optional dependency whose install script fails. That is
|
||||
// expected on Linux and win32-arm64 because get-windows 9.3.0 publishes no
|
||||
// native prebuilt for either target. The runtime import already fails soft,
|
||||
// so disable only window enumeration instead of failing the Desktop build.
|
||||
// Other Windows architectures and macOS have supported native payloads and
|
||||
// remain fail-closed so a broken package cannot ship silently.
|
||||
const canDegrade = platform === 'linux' || (platform === 'win32' && arch === 'arm64')
|
||||
if (canDegrade) {
|
||||
console.warn(
|
||||
`[stage-native-deps] get-windows not installed (optional dep skipped for ${platform}-${arch}); ` +
|
||||
'read_window_below will be unavailable in this build'
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
throw new Error(
|
||||
`[stage-native-deps] get-windows is not installed; cannot stage its ${platform}-${arch} native payload`
|
||||
)
|
||||
}
|
||||
|
||||
// Only a win32 host can produce the win32 binding, so a cross-platform pack
|
||||
// has nothing to gain from the native installer.
|
||||
const install =
|
||||
platform === 'win32' && process.platform === 'win32'
|
||||
? () => installGetWindowsNativeBinding(srcRoot)
|
||||
: undefined
|
||||
return stageGetWindowsInto(srcRoot, destRoot, { platform, arch, install })
|
||||
}
|
||||
|
||||
// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch]
|
||||
if (isMain(import.meta.url)) {
|
||||
const [platform, arch] = process.argv.slice(2)
|
||||
stageNodePty({ platform, arch })
|
||||
stageGetWindows({ platform, arch })
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs, { existsSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
installGetWindowsNativeBinding,
|
||||
stageGetWindows,
|
||||
stageGetWindowsInto,
|
||||
stageNodePtyInto,
|
||||
classifyNativeBinary
|
||||
} from '../scripts/stage-native-deps.mjs'
|
||||
|
||||
const { join } = path
|
||||
|
||||
// ─── fixtures ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Create minimal fake .node files with correct magic bytes so the
|
||||
// binary classifier and the staging validator exercise real code paths
|
||||
// without needing actual native modules.
|
||||
|
||||
/** Write a fake .node file with the given platform's magic bytes. */
|
||||
function makeFakeNode(filePath, platform) {
|
||||
const headers = {
|
||||
linux: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]), // ELF
|
||||
// On x64/arm64 Darwin, Mach-O binaries are stored little-endian on disk
|
||||
// (MH_CIGAM_64 = cffaedfe). This is the form node-pty's prebuilds ship in.
|
||||
darwin: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00]), // Mach-O 64-bit LE (CIGAM_64)
|
||||
win32: Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // MZ (PE)
|
||||
}
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, headers[platform] ?? headers.linux)
|
||||
}
|
||||
|
||||
/** Create a minimal fake node-pty source tree in a temp dir. */
|
||||
function makeFakeNodePty(srcRoot, { prebuildPlatform, prebuildArch } = {}) {
|
||||
fs.mkdirSync(srcRoot, { recursive: true })
|
||||
fs.writeFileSync(join(srcRoot, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'lib/index.js' }))
|
||||
fs.mkdirSync(join(srcRoot, 'lib'), { recursive: true })
|
||||
fs.writeFileSync(join(srcRoot, 'lib', 'index.js'), 'module.exports = {};')
|
||||
|
||||
if (prebuildPlatform && prebuildArch) {
|
||||
const prebuildDir = join(srcRoot, 'prebuilds', `${prebuildPlatform}-${prebuildArch}`)
|
||||
makeFakeNode(join(prebuildDir, 'pty.node'), prebuildPlatform)
|
||||
}
|
||||
}
|
||||
|
||||
function makeFakeUnixTerminal(srcRoot) {
|
||||
fs.writeFileSync(
|
||||
join(srcRoot, 'lib', 'unixTerminal.js'),
|
||||
[
|
||||
"exports.resolveHelper = function (helperPath) {",
|
||||
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
' return helperPath;',
|
||||
'};'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
// ─── classifyNativeBinary tests ─────────────────────────────────────
|
||||
|
||||
test('classifyNativeBinary detects ELF as linux', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'linux')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Mach-O 64-bit BE as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xcf, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Mach-O 64-bit LE (CIGAM_64) as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Mach-O 32-bit BE as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xce, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Mach-O 32-bit LE (CIGAM) as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xce, 0xfa, 0xed, 0xfe, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Fat/Universal BE (cafebabe) as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects Fat/Universal LE (bebafeca / FAT_CIGAM) as darwin', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0xbe, 0xba, 0xfe, 0xca, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'darwin')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary detects PE (MZ) as win32', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), 'win32')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary returns null for unrecognized magic', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const f = join(tmp, 'test.node')
|
||||
fs.writeFileSync(f, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
|
||||
assert.equal(classifyNativeBinary(f), null)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('classifyNativeBinary returns null for a missing file', () => {
|
||||
assert.equal(classifyNativeBinary('/nonexistent/path/to/thing.node'), null)
|
||||
})
|
||||
|
||||
// ─── cross-target regression tests ──────────────────────────────────
|
||||
//
|
||||
// The core bug: stageNodePty receives { platform, arch } from
|
||||
// electron-builder but unconditionally copies host build/Release, staging
|
||||
// a host binary for a foreign target. These tests prove the fix:
|
||||
//
|
||||
// 1. A host build/Release must NOT be staged for a foreign platform.
|
||||
// 2. A matching prebuild IS staged for a foreign target.
|
||||
// 3. A foreign target with no prebuild throws (fail closed).
|
||||
// 4. A host build/Release IS staged for a matching target.
|
||||
// 5. Validation rejects a binary whose magic bytes don't match the target.
|
||||
|
||||
test('cross-target: host build/Release is NOT staged for a foreign platform', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// Create a node-pty tree with ONLY a host build/Release (no prebuild).
|
||||
makeFakeNodePty(srcRoot)
|
||||
const buildReleaseDir = join(srcRoot, 'build', 'Release')
|
||||
makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform)
|
||||
|
||||
// Request a foreign platform (different from the host).
|
||||
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
|
||||
|
||||
assert.throws(
|
||||
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
|
||||
/cannot cross-compile/i
|
||||
)
|
||||
|
||||
// build/Release must NOT have been copied to the dest tree.
|
||||
assert.equal(
|
||||
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
|
||||
false,
|
||||
'host build/Release .node must not be staged for a foreign target'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cross-target: matching prebuild IS staged for a foreign target', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// Host is (say) darwin. Request linux-x64, which has a prebuild.
|
||||
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
|
||||
makeFakeNodePty(srcRoot, { prebuildPlatform: foreignPlatform, prebuildArch: 'x64' })
|
||||
|
||||
// Also create a host build/Release that should NOT be staged.
|
||||
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
|
||||
|
||||
stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' })
|
||||
|
||||
// The foreign prebuild must be staged.
|
||||
const stagedPrebuild = join(destRoot, 'prebuilds', `${foreignPlatform}-x64`, 'pty.node')
|
||||
assert.equal(existsSync(stagedPrebuild), true, 'foreign prebuild must be staged')
|
||||
|
||||
// The host build/Release must NOT be staged.
|
||||
assert.equal(
|
||||
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
|
||||
false,
|
||||
'host build/Release must not be staged for a foreign target'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cross-target: foreign target with no prebuild throws (fail closed)', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// Create a tree with a host build/Release but no foreign prebuild.
|
||||
makeFakeNodePty(srcRoot)
|
||||
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
|
||||
|
||||
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
|
||||
|
||||
assert.throws(
|
||||
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
|
||||
/cannot cross-compile/i
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('host-target: host build/Release IS staged for a matching target', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
makeFakeNodePty(srcRoot)
|
||||
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
|
||||
|
||||
stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch })
|
||||
|
||||
assert.equal(
|
||||
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
|
||||
true,
|
||||
'host build/Release must be staged for a matching target'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test.skipIf(process.platform === 'win32')(
|
||||
'host-target: staged node-pty resolves an already-unpacked helper and preserves executable helpers',
|
||||
async () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
const prebuildDir = join(srcRoot, 'prebuilds', `${process.platform}-${process.arch}`)
|
||||
const buildReleaseDir = join(srcRoot, 'build', 'Release')
|
||||
|
||||
makeFakeNodePty(srcRoot, {
|
||||
prebuildPlatform: process.platform,
|
||||
prebuildArch: process.arch
|
||||
})
|
||||
makeFakeUnixTerminal(srcRoot)
|
||||
makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform)
|
||||
fs.writeFileSync(join(prebuildDir, 'spawn-helper'), 'prebuild helper')
|
||||
fs.writeFileSync(join(buildReleaseDir, 'spawn-helper'), 'build helper')
|
||||
fs.chmodSync(join(prebuildDir, 'spawn-helper'), 0o644)
|
||||
fs.chmodSync(join(buildReleaseDir, 'spawn-helper'), 0o644)
|
||||
|
||||
stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch })
|
||||
|
||||
const stagedUnixTerminalUrl = pathToFileURL(join(destRoot, 'lib', 'unixTerminal.js'))
|
||||
stagedUnixTerminalUrl.searchParams.set('t', String(Date.now()))
|
||||
const stagedUnixTerminal = await import(stagedUnixTerminalUrl.href)
|
||||
const unpackedHelper = join(
|
||||
tmp,
|
||||
'Hermes.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'app.asar.unpacked',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'prebuilds',
|
||||
`${process.platform}-${process.arch}`,
|
||||
'spawn-helper'
|
||||
)
|
||||
const nodeModulesUnpackedHelper = unpackedHelper.replace(
|
||||
`${path.sep}node_modules${path.sep}`,
|
||||
`${path.sep}node_modules.asar.unpacked${path.sep}`
|
||||
)
|
||||
|
||||
assert.equal(stagedUnixTerminal.resolveHelper(unpackedHelper), unpackedHelper)
|
||||
assert.equal(
|
||||
stagedUnixTerminal.resolveHelper(nodeModulesUnpackedHelper),
|
||||
nodeModulesUnpackedHelper
|
||||
)
|
||||
assert.equal(
|
||||
fs.statSync(join(destRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper')).mode & 0o777,
|
||||
0o755
|
||||
)
|
||||
assert.equal(fs.statSync(join(destRoot, 'build', 'Release', 'spawn-helper')).mode & 0o777, 0o755)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test('validation rejects a staged binary with the wrong platform magic', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'node-pty')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// Create a prebuild dir that claims to be linux-x64 but contains
|
||||
// a darwin (Mach-O) binary. This simulates the original bug where
|
||||
// a host binary ends up in a foreign target's prebuild slot.
|
||||
makeFakeNodePty(srcRoot, { prebuildPlatform: 'linux', prebuildArch: 'x64' })
|
||||
// Overwrite the prebuild .node with the WRONG platform magic.
|
||||
makeFakeNode(join(srcRoot, 'prebuilds', 'linux-x64', 'pty.node'), 'darwin')
|
||||
|
||||
assert.throws(
|
||||
() => stageNodePtyInto(srcRoot, destRoot, { platform: 'linux', arch: 'x64' }),
|
||||
/platform mismatch/i
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── stageGetWindowsInto tests ──────────────────────────────────────
|
||||
|
||||
/** Create a minimal fake get-windows source tree in a temp dir. */
|
||||
function makeFakeGetWindows(srcRoot, { version = '9.3.0', bindings = [] } = {}) {
|
||||
fs.mkdirSync(join(srcRoot, 'lib'), { recursive: true })
|
||||
fs.writeFileSync(join(srcRoot, 'package.json'), JSON.stringify({ name: 'get-windows', version, main: 'index.js' }))
|
||||
fs.writeFileSync(join(srcRoot, 'index.js'), 'export {};')
|
||||
fs.writeFileSync(join(srcRoot, 'lib', 'windows.js'), '// upstream pre-gyp loader')
|
||||
fs.writeFileSync(join(srcRoot, 'main'), '#!/bin/sh\n')
|
||||
|
||||
for (const { dir, platform } of bindings) {
|
||||
makeFakeNode(join(srcRoot, 'lib', 'binding', dir, 'node-get-windows.node'), platform)
|
||||
}
|
||||
}
|
||||
|
||||
test('win32 staging skips the darwin binding the tarball bundles on every platform', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'get-windows')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// The shape every real Windows build host has: the darwin binding
|
||||
// committed into the published tarball PLUS the win32 binding
|
||||
// node-pre-gyp downloaded at install time.
|
||||
makeFakeGetWindows(srcRoot, {
|
||||
bindings: [
|
||||
{ dir: 'napi-9-darwin-unknown-arm64', platform: 'darwin' },
|
||||
{ dir: 'napi-9-win32-unknown-x64', platform: 'win32' }
|
||||
]
|
||||
})
|
||||
|
||||
stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64' })
|
||||
|
||||
assert.ok(existsSync(join(destRoot, 'lib', 'binding', 'napi-9-win32-unknown-x64', 'node-get-windows.node')))
|
||||
assert.ok(!existsSync(join(destRoot, 'lib', 'binding', 'napi-9-darwin-unknown-arm64')))
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('win32 staging rejects a binding dir that claims win32 but holds a foreign binary', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'get-windows')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
makeFakeGetWindows(srcRoot, {
|
||||
bindings: [{ dir: 'napi-9-win32-unknown-x64', platform: 'darwin' }]
|
||||
})
|
||||
|
||||
assert.throws(
|
||||
() => stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64' }),
|
||||
/expected win32, got darwin/
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('win32-x64 staging fails when only foreign bindings exist', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'get-windows')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
makeFakeGetWindows(srcRoot, {
|
||||
bindings: [{ dir: 'napi-9-darwin-unknown-arm64', platform: 'darwin' }]
|
||||
})
|
||||
|
||||
assert.throws(
|
||||
() => stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64' }),
|
||||
/no win32-x64 prebuilt binding/
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('win32-arm64 staging omits incompatible bindings and keeps the fail-soft JS surface', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'get-windows')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
makeFakeGetWindows(srcRoot, {
|
||||
bindings: [
|
||||
{ dir: 'napi-9-darwin-unknown-arm64', platform: 'darwin' },
|
||||
{ dir: 'napi-9-win32-unknown-x64', platform: 'win32' }
|
||||
]
|
||||
})
|
||||
|
||||
stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'arm64' })
|
||||
|
||||
assert.ok(existsSync(join(destRoot, 'lib', 'windows.js')))
|
||||
assert.ok(!existsSync(join(destRoot, 'lib', 'binding')))
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('win32 staging self-heals through the native installer when the binding is missing', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'get-windows')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
// The bricked state a blocked install script leaves behind: the package is
|
||||
// present, lib/binding was never populated by node-pre-gyp.
|
||||
makeFakeGetWindows(srcRoot, { bindings: [] })
|
||||
|
||||
let calls = 0
|
||||
const install = () => {
|
||||
calls += 1
|
||||
makeFakeNode(
|
||||
join(srcRoot, 'lib', 'binding', 'napi-9-win32-unknown-x64', 'node-get-windows.node'),
|
||||
'win32'
|
||||
)
|
||||
}
|
||||
|
||||
stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64', install })
|
||||
|
||||
assert.equal(calls, 1)
|
||||
assert.ok(
|
||||
existsSync(join(destRoot, 'lib', 'binding', 'napi-9-win32-unknown-x64', 'node-get-windows.node'))
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('win32 staging rejects a successful installer that produces no binding', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'get-windows')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
makeFakeGetWindows(srcRoot, { bindings: [] })
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
stageGetWindowsInto(srcRoot, destRoot, {
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
install: () => {}
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /installer completed without producing a win32-x64 binding/)
|
||||
assert.doesNotMatch(error.message, /npm rebuild/)
|
||||
return true
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('get-windows native install invokes node-pre-gyp directly from the package root', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'get-windows')
|
||||
const installer = join(
|
||||
srcRoot,
|
||||
'node_modules',
|
||||
'@mapbox',
|
||||
'node-pre-gyp',
|
||||
'bin',
|
||||
'node-pre-gyp'
|
||||
)
|
||||
fs.mkdirSync(path.dirname(installer), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
join(srcRoot, 'node_modules', '@mapbox', 'node-pre-gyp', 'package.json'),
|
||||
JSON.stringify({ name: '@mapbox/node-pre-gyp', version: '1.0.11' })
|
||||
)
|
||||
fs.writeFileSync(installer, '')
|
||||
|
||||
const calls = []
|
||||
installGetWindowsNativeBinding(srcRoot, {
|
||||
spawn: (command, args, options) => {
|
||||
calls.push({ command, args, options })
|
||||
return { status: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: process.execPath,
|
||||
args: [fs.realpathSync(installer), 'install', '--fallback-to-build'],
|
||||
options: { cwd: srcRoot, stdio: 'inherit' }
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('get-windows native install surfaces node-pre-gyp failure', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
installGetWindowsNativeBinding('C:\\fake\\get-windows', {
|
||||
resolveInstaller: () => 'C:\\fake\\node-pre-gyp',
|
||||
spawn: () => ({ status: 1 })
|
||||
}),
|
||||
/native installer exited with 1/
|
||||
)
|
||||
})
|
||||
|
||||
test('staging refuses a get-windows version the lib/windows.js rewrite was not verified against', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'get-windows')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
makeFakeGetWindows(srcRoot, { version: '9.4.0' })
|
||||
|
||||
assert.throws(
|
||||
() => stageGetWindowsInto(srcRoot, destRoot, { platform: 'darwin' }),
|
||||
/verified against 9\.3\.0/
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('darwin staging ships the Swift helper executable and the rewritten windows.js', () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
|
||||
try {
|
||||
const srcRoot = join(tmp, 'get-windows')
|
||||
const destRoot = join(tmp, 'dest')
|
||||
|
||||
makeFakeGetWindows(srcRoot)
|
||||
|
||||
stageGetWindowsInto(srcRoot, destRoot, { platform: 'darwin' })
|
||||
|
||||
assert.equal(fs.statSync(join(destRoot, 'main')).mode & 0o777, 0o755)
|
||||
const staged = fs.readFileSync(join(destRoot, 'lib', 'windows.js'), 'utf8')
|
||||
assert.match(staged, /Rewritten by stage-native-deps\.mjs/)
|
||||
assert.ok(!staged.includes('node-pre-gyp'), 'pre-gyp loader must not survive staging')
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── stageGetWindows (optionalDependency gate) ──────────────────────
|
||||
//
|
||||
// get-windows is an optionalDependency: on Linux its node-pre-gyp install
|
||||
// script fails because no prebuilt exists. Windows ARM64 has the same package
|
||||
// state: its prebuilt URL returns 404 and npm may omit the optional dependency.
|
||||
// Staging skips those unsupported targets, but supported native targets remain
|
||||
// a hard failure when the package is missing.
|
||||
|
||||
test('linux staging skips when get-windows is absent (optional dep skipped by npm)', () => {
|
||||
assert.equal(stageGetWindows({ platform: 'linux', resolveRoot: () => null }), undefined)
|
||||
})
|
||||
|
||||
test('darwin staging fails when get-windows is absent', () => {
|
||||
assert.throws(
|
||||
() => stageGetWindows({ platform: 'darwin', arch: 'arm64', resolveRoot: () => null }),
|
||||
/get-windows is not installed/
|
||||
)
|
||||
})
|
||||
|
||||
test('win32-arm64 staging skips when get-windows is absent after its optional install fails', () => {
|
||||
assert.equal(
|
||||
stageGetWindows({ platform: 'win32', arch: 'arm64', resolveRoot: () => null }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
test('win32-x64 staging fails when get-windows is absent', () => {
|
||||
assert.throws(
|
||||
() => stageGetWindows({ platform: 'win32', arch: 'x64', resolveRoot: () => null }),
|
||||
/get-windows is not installed/
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,442 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { listPackage } from '@electron/asar'
|
||||
|
||||
import PACKAGE_JSON from '../package.json' with { type: 'json' }
|
||||
|
||||
const MODE = process.argv[2] || 'help'
|
||||
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
||||
const PLATFORM = process.platform
|
||||
|
||||
// Platform-specific packaged-app layout. The thin installer ships an Electron
|
||||
// app shell plus extraResources (install-stamp.json + native-deps/) -- it
|
||||
// no longer bundles the Hermes Agent Python payload (that's fetched at first
|
||||
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
|
||||
const APP = (() => {
|
||||
if (PLATFORM === 'darwin') {
|
||||
const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
|
||||
return {
|
||||
appPath,
|
||||
binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'),
|
||||
resourcesPath: path.join(appPath, 'Contents', 'Resources'),
|
||||
asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||
unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||
}
|
||||
}
|
||||
if (PLATFORM === 'win32') {
|
||||
const unpacked = path.join(RELEASE_ROOT, 'win-unpacked')
|
||||
return {
|
||||
appPath: unpacked,
|
||||
binary: path.join(unpacked, 'Hermes.exe'),
|
||||
resourcesPath: path.join(unpacked, 'resources'),
|
||||
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
||||
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||
}
|
||||
}
|
||||
// linux unpacked layout matches windows but with different binary name
|
||||
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
|
||||
return {
|
||||
appPath: unpacked,
|
||||
binary: path.join(unpacked, 'Hermes'),
|
||||
resourcesPath: path.join(unpacked, 'resources'),
|
||||
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
||||
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||
}
|
||||
})()
|
||||
|
||||
// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's
|
||||
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
|
||||
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
|
||||
// HERMES_HOME and never touches this.
|
||||
const DEFAULT_HERMES_HOME = (() => {
|
||||
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) {
|
||||
return path.join(process.env.LOCALAPPDATA, 'hermes')
|
||||
}
|
||||
return path.join(os.homedir(), '.hermes')
|
||||
})()
|
||||
const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv')
|
||||
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
|
||||
|
||||
function die(message) {
|
||||
console.error(`\n${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd || DESKTOP_ROOT,
|
||||
env: options.env || process.env,
|
||||
shell: Boolean(options.shell) || PLATFORM === 'win32',
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
die(`${command} ${args.join(' ')} failed`)
|
||||
}
|
||||
}
|
||||
|
||||
function exists(target) {
|
||||
return fs.existsSync(target)
|
||||
}
|
||||
|
||||
// Match node-pty native binding location to what the bundled electron-main.cjs
|
||||
// resolves at runtime. stage-native-deps.mjs stages node-pty into
|
||||
// dist/node_modules/node-pty, and dist/** is asarUnpacked (see package.json
|
||||
// build.asarUnpack), so in a packaged build it lands under
|
||||
// resources/app.asar.unpacked/dist/node_modules/node-pty — reachable by a bare
|
||||
// require('node-pty') from the bundle. Upstream node-pty 1.x is N-API based and
|
||||
// ships per-arch prebuilts under prebuilds/<platform>-<arch>/; nix/local builds
|
||||
// instead compile from source into build/Release/. The stage script copies
|
||||
// whichever is present, so we accept either as the native payload.
|
||||
function expectedNativeDepPaths() {
|
||||
const root = path.join(APP.resourcesPath, 'app.asar.unpacked', 'dist', 'node_modules', 'node-pty')
|
||||
const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`)
|
||||
const buildReleaseDir = path.join(root, 'build', 'Release')
|
||||
return {
|
||||
packageJson: path.join(root, 'package.json'),
|
||||
prebuildsDir,
|
||||
buildReleaseDir,
|
||||
libIndex: path.join(root, 'lib', 'index.js')
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePlatformBuilds() {
|
||||
if (PLATFORM === 'darwin') return
|
||||
if (PLATFORM === 'win32') return
|
||||
if (PLATFORM === 'linux') return
|
||||
die(
|
||||
`Desktop bundle validation is only wired for darwin / win32 / linux; platform=${PLATFORM} is not supported.`
|
||||
)
|
||||
}
|
||||
|
||||
function ensurePackagedApp() {
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP.binary)) {
|
||||
return
|
||||
}
|
||||
|
||||
run('npm', ['run', 'pack'])
|
||||
}
|
||||
|
||||
function resolveDmgPath() {
|
||||
if (!exists(RELEASE_ROOT)) {
|
||||
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
||||
}
|
||||
|
||||
const prefix = `Hermes-${PACKAGE_JSON.version}`
|
||||
const candidates = fs
|
||||
.readdirSync(RELEASE_ROOT)
|
||||
.filter(name => name.endsWith('.dmg'))
|
||||
.filter(name => name.startsWith(prefix))
|
||||
.filter(name => name.includes(ARCH))
|
||||
.sort((a, b) => {
|
||||
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
|
||||
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
|
||||
return bMtime - aMtime
|
||||
})
|
||||
|
||||
return candidates.length > 0
|
||||
? path.join(RELEASE_ROOT, candidates[0])
|
||||
: path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
||||
}
|
||||
|
||||
function resolveNsisPath() {
|
||||
// electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}'
|
||||
if (!exists(RELEASE_ROOT)) return null
|
||||
const candidates = fs
|
||||
.readdirSync(RELEASE_ROOT)
|
||||
.filter(name => /\.exe$/i.test(name) && /win/i.test(name))
|
||||
.sort((a, b) => {
|
||||
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
|
||||
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
|
||||
return bMtime - aMtime
|
||||
})
|
||||
return candidates.length > 0 ? path.join(RELEASE_ROOT, candidates[0]) : null
|
||||
}
|
||||
|
||||
function ensureDmg() {
|
||||
if (PLATFORM !== 'darwin') {
|
||||
die('DMG mode is macOS-only; on Windows use the `nsis` mode instead.')
|
||||
}
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) {
|
||||
return
|
||||
}
|
||||
run('npm', ['run', 'dist:mac:dmg'])
|
||||
}
|
||||
|
||||
function ensureNsis() {
|
||||
if (PLATFORM !== 'win32') {
|
||||
die('NSIS mode is win32-only; on macOS use the `dmg` mode instead.')
|
||||
}
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveNsisPath()) {
|
||||
return
|
||||
}
|
||||
run('npm', ['run', 'dist:win:nsis'])
|
||||
}
|
||||
|
||||
function openApp() {
|
||||
if (!exists(APP.binary)) {
|
||||
die(`Missing packaged app: ${APP.binary}`)
|
||||
}
|
||||
|
||||
if (PLATFORM === 'darwin') {
|
||||
run('open', ['-n', APP.appPath])
|
||||
} else if (PLATFORM === 'win32') {
|
||||
// Spawn detached so the test script exits while the app keeps running.
|
||||
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
|
||||
} else {
|
||||
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
|
||||
}
|
||||
}
|
||||
|
||||
function openDmg() {
|
||||
if (PLATFORM !== 'darwin') {
|
||||
die('DMG mode is macOS-only.')
|
||||
}
|
||||
const dmgPath = resolveDmgPath()
|
||||
if (!exists(dmgPath)) {
|
||||
die(`Missing DMG: ${dmgPath}`)
|
||||
}
|
||||
run('open', [dmgPath])
|
||||
}
|
||||
|
||||
const CREDENTIAL_ENV_SUFFIXES = [
|
||||
'_API_KEY',
|
||||
'_TOKEN',
|
||||
'_SECRET',
|
||||
'_PASSWORD',
|
||||
'_CREDENTIALS',
|
||||
'_ACCESS_KEY',
|
||||
'_PRIVATE_KEY',
|
||||
'_OAUTH_TOKEN'
|
||||
]
|
||||
|
||||
const CREDENTIAL_ENV_NAMES = new Set([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_TOKEN',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_SESSION_TOKEN',
|
||||
'CUSTOM_API_KEY',
|
||||
'GEMINI_BASE_URL',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENROUTER_BASE_URL',
|
||||
'OLLAMA_BASE_URL',
|
||||
'GROQ_BASE_URL',
|
||||
'XAI_BASE_URL'
|
||||
])
|
||||
|
||||
function isCredentialEnvVar(name) {
|
||||
if (CREDENTIAL_ENV_NAMES.has(name)) return true
|
||||
return CREDENTIAL_ENV_SUFFIXES.some(suffix => name.endsWith(suffix))
|
||||
}
|
||||
|
||||
function launchFresh() {
|
||||
if (!exists(APP.binary)) {
|
||||
die(`Missing app executable: ${APP.binary}`)
|
||||
}
|
||||
|
||||
const sandbox = fs.mkdtempSync(`${FRESH_SANDBOX_ROOT}-`)
|
||||
const userDataDir = path.join(sandbox, 'electron-user-data')
|
||||
const hermesHome = path.join(sandbox, 'hermes-home')
|
||||
const cwd = path.join(sandbox, 'workspace')
|
||||
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.mkdirSync(cwd, { recursive: true })
|
||||
|
||||
// Strip every credential-shaped env var so the sandbox is actually fresh.
|
||||
const env = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (isCredentialEnvVar(key)) continue
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
env.HERMES_DESKTOP_CWD = cwd
|
||||
env.HERMES_DESKTOP_IGNORE_EXISTING = '1'
|
||||
env.HERMES_DESKTOP_TEST_MODE = 'fresh-install'
|
||||
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
|
||||
env.HERMES_HOME = hermesHome
|
||||
delete env.HERMES_DESKTOP_HERMES
|
||||
delete env.HERMES_DESKTOP_HERMES_ROOT
|
||||
|
||||
const child = spawn(APP.binary, [], {
|
||||
cwd: os.homedir(),
|
||||
detached: true,
|
||||
env,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
child.unref()
|
||||
|
||||
console.log('\nFresh install sandbox:')
|
||||
console.log(` root: ${sandbox}`)
|
||||
console.log(` electron userData: ${userDataDir}`)
|
||||
console.log(` HERMES_HOME: ${hermesHome}`)
|
||||
console.log(` cwd: ${cwd}`)
|
||||
|
||||
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
|
||||
}
|
||||
|
||||
// Validate the packaged bundle matches the thin-installer architecture:
|
||||
// - The Hermes Agent Python payload is NOT shipped (it's fetched at first
|
||||
// launch via install.ps1's stage protocol).
|
||||
// - install-stamp.json IS shipped in resources/ with a valid commit + branch.
|
||||
// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty
|
||||
// with package.json + lib/ + at least one .node binary (the renderer's
|
||||
// integrated terminal needs this; see Phase 1F.6).
|
||||
// - The renderer's dist/index.html is reachable (either unpacked or
|
||||
// inside app.asar).
|
||||
function validateBundle() {
|
||||
if (!exists(APP.binary)) {
|
||||
die(`Missing packaged app binary: ${APP.binary}`)
|
||||
}
|
||||
|
||||
// Negative assertion: the OLD fat-installer factory payload must NOT be
|
||||
// present anymore. If a stray ship of hermes_cli sneaks back in we want
|
||||
// to fail loudly rather than re-introduce the 400MB delta we just removed.
|
||||
const staleFactoryMarker = path.join(APP.resourcesPath, 'hermes-agent', 'hermes_cli', 'main.py')
|
||||
if (exists(staleFactoryMarker)) {
|
||||
die(
|
||||
`Thin-installer regression: factory-payload file should NOT be in the package: ${staleFactoryMarker}`
|
||||
)
|
||||
}
|
||||
|
||||
// Positive assertion: install-stamp.json carries a sane commit + branch
|
||||
const stampPath = path.join(APP.resourcesPath, 'install-stamp.json')
|
||||
if (!exists(stampPath)) {
|
||||
die(`Missing install-stamp.json (required for first-launch bootstrap pinning): ${stampPath}`)
|
||||
}
|
||||
let stamp
|
||||
try {
|
||||
stamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'))
|
||||
} catch (err) {
|
||||
die(`install-stamp.json is not valid JSON: ${err.message}`)
|
||||
}
|
||||
if (!stamp.commit || typeof stamp.commit !== 'string' || stamp.commit.length < 7) {
|
||||
die(`install-stamp.json is missing a usable commit field: ${JSON.stringify(stamp)}`)
|
||||
}
|
||||
if (!stamp.branch || typeof stamp.branch !== 'string') {
|
||||
die(`install-stamp.json is missing the branch field: ${JSON.stringify(stamp)}`)
|
||||
}
|
||||
|
||||
// Positive assertion: node-pty native deps shipped
|
||||
const native = expectedNativeDepPaths()
|
||||
if (!exists(native.packageJson)) {
|
||||
die(`Missing node-pty package.json in app.asar.unpacked: ${native.packageJson}`)
|
||||
}
|
||||
if (!exists(native.libIndex)) {
|
||||
die(`Missing node-pty lib/index.js in app.asar.unpacked: ${native.libIndex}`)
|
||||
}
|
||||
// The native binary lands in prebuilds/<platform>-<arch>/ (downloaded prebuild)
|
||||
// OR build/Release/ (compiled from source). stage-native-deps.mjs copies
|
||||
// whichever is present, so accept either.
|
||||
const nativeBinaryDirs = [native.prebuildsDir, native.buildReleaseDir].filter(exists)
|
||||
if (nativeBinaryDirs.length === 0) {
|
||||
die(
|
||||
`Missing node-pty native binary dir for ${PLATFORM}-${ARCH}: neither ` +
|
||||
`${native.prebuildsDir} nor ${native.buildReleaseDir} exists`
|
||||
)
|
||||
}
|
||||
const nodeBinaries = nativeBinaryDirs.flatMap(dir =>
|
||||
fs.readdirSync(dir).filter(name => name.endsWith('.node'))
|
||||
)
|
||||
if (nodeBinaries.length === 0) {
|
||||
die(`No .node native binaries found in: ${nativeBinaryDirs.join(', ')}`)
|
||||
}
|
||||
// Darwin requires a runtime-execed spawn-helper alongside pty.node; missing
|
||||
// it manifests as "ENOENT: spawn-helper" on first pty.spawn() call.
|
||||
if (PLATFORM === 'darwin') {
|
||||
const spawnHelper = nativeBinaryDirs
|
||||
.map(dir => path.join(dir, 'spawn-helper'))
|
||||
.find(exists)
|
||||
if (!spawnHelper) {
|
||||
die(`Missing node-pty spawn-helper (required on darwin) in: ${nativeBinaryDirs.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer payload check (either unpacked or in the asar)
|
||||
if (exists(APP.unpackedDistIndex)) {
|
||||
return { stamp, nodeBinaries }
|
||||
}
|
||||
if (!exists(APP.asarPath)) {
|
||||
die(`Missing renderer payload: neither ${APP.unpackedDistIndex} nor ${APP.asarPath} exists`)
|
||||
}
|
||||
const files = listPackage(APP.asarPath)
|
||||
// Normalize separators because @electron/asar's listPackage returns
|
||||
// backslash-prefixed entries on Windows ('\\dist\\index.html') and
|
||||
// forward-slash on Unix.
|
||||
const normalized = files.map(f => f.replace(/\\/g, '/').replace(/^\/+/, ''))
|
||||
if (!normalized.includes('dist/index.html')) {
|
||||
die(`Missing renderer payload file in app.asar: ${APP.asarPath} (expected dist/index.html)`)
|
||||
}
|
||||
return { stamp, nodeBinaries }
|
||||
}
|
||||
|
||||
function printArtifacts(options = {}) {
|
||||
const runtimeRoot = options.runtimeRoot || VENV_ROOT
|
||||
const stamp = options.stamp
|
||||
|
||||
console.log('\nDesktop artifacts:')
|
||||
console.log(` app: ${APP.appPath}`)
|
||||
if (PLATFORM === 'darwin') {
|
||||
console.log(` dmg: ${resolveDmgPath()}`)
|
||||
} else if (PLATFORM === 'win32') {
|
||||
const exe = resolveNsisPath()
|
||||
if (exe) console.log(` installer: ${exe}`)
|
||||
}
|
||||
console.log(` runtime: ${runtimeRoot}`)
|
||||
if (stamp) {
|
||||
console.log(` install-stamp: ${stamp.commit.slice(0, 12)} on ${stamp.branch}`)
|
||||
}
|
||||
if (options.nodeBinaries && options.nodeBinaries.length > 0) {
|
||||
console.log(` node-pty binaries: ${options.nodeBinaries.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function help() {
|
||||
console.log(`Usage:
|
||||
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
|
||||
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
|
||||
npm run test:desktop:dmg # (macOS only) build DMG and open it
|
||||
npm run test:desktop:nsis # (win32 only) build NSIS installer
|
||||
npm run test:desktop:all # build installer, validate app payload, print paths
|
||||
|
||||
Fast rerun (skip rebuild if the packaged app already exists):
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
|
||||
`)
|
||||
}
|
||||
|
||||
ensurePlatformBuilds()
|
||||
|
||||
if (MODE === 'existing') {
|
||||
ensurePackagedApp()
|
||||
const result = validateBundle()
|
||||
openApp()
|
||||
printArtifacts(result)
|
||||
} else if (MODE === 'fresh') {
|
||||
ensurePackagedApp()
|
||||
const result = validateBundle()
|
||||
printArtifacts({ ...launchFresh(), ...result })
|
||||
} else if (MODE === 'dmg') {
|
||||
ensureDmg()
|
||||
openDmg()
|
||||
printArtifacts()
|
||||
} else if (MODE === 'nsis') {
|
||||
ensureNsis()
|
||||
printArtifacts(validateBundle())
|
||||
} else if (MODE === 'all') {
|
||||
if (PLATFORM === 'darwin') {
|
||||
ensureDmg()
|
||||
} else if (PLATFORM === 'win32') {
|
||||
ensureNsis()
|
||||
} else {
|
||||
ensurePackagedApp()
|
||||
}
|
||||
printArtifacts(validateBundle())
|
||||
} else {
|
||||
help()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
// returns true if the passsed file is being invoked from node,
|
||||
// not imported.
|
||||
export function isMain(importMetaUrl) {
|
||||
return importMetaUrl === pathToFileURL(process.argv[1]).href;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
|
||||
* .exe should pin to at first-launch bootstrap time. This file ships inside
|
||||
* the packaged app via electron-builder's extraResources entry and is read
|
||||
* by electron/main.ts to drive the install.ps1 stage bootstrap flow.
|
||||
*
|
||||
* Schema (subject to bump via STAMP_SCHEMA_VERSION):
|
||||
* {
|
||||
* "schemaVersion": 1,
|
||||
* "commit": "<40-char SHA>",
|
||||
* "branch": "<branch name>",
|
||||
* "builtAt": "<ISO 8601 UTC timestamp>",
|
||||
* "dirty": true|false,
|
||||
* "source": "ci" | "local" | "fallback"
|
||||
* }
|
||||
*
|
||||
* Source preference order:
|
||||
* 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
|
||||
* shallow clones, detached HEADs, etc. in CI.
|
||||
* 2. Local `git rev-parse` against the parent repo (../..).
|
||||
* 3. Fallback stamp for local/personal builds from non-git source trees
|
||||
* (ZIP extract, interrupted clone with no HEAD, etc.).
|
||||
*
|
||||
* Dev / out-of-repo builds without git produce an explicit fallback stamp
|
||||
* rather than aborting the whole build. Bootstrap treats the all-zero
|
||||
* commit as unpinned and follows the branch instead of fetching a fake SHA.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from "fs"
|
||||
import { resolve, join, relative } from "path"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
import { isMain } from "./utils.mjs"
|
||||
|
||||
const STAMP_SCHEMA_VERSION = 1
|
||||
|
||||
/** All-zero placeholder used when no real commit can be resolved. */
|
||||
export const FALLBACK_COMMIT = "0000000000000000000000000000000000000000"
|
||||
export const FALLBACK_BRANCH = "aiturk/main"
|
||||
|
||||
const DESKTOP_ROOT = resolve(import.meta.dirname, "..")
|
||||
const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..")
|
||||
const OUT_DIR = join(DESKTOP_ROOT, "build")
|
||||
const OUT_FILE = join(OUT_DIR, "install-stamp.json")
|
||||
|
||||
function tryExec(cmd, opts) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function fromCI(env = process.env) {
|
||||
const sha = env.GITHUB_SHA
|
||||
if (!sha) return null
|
||||
const branch = env.GITHUB_REF_NAME || env.GITHUB_HEAD_REF || null
|
||||
return {
|
||||
commit: sha,
|
||||
branch: branch,
|
||||
dirty: false, // CI builds from a checkout-of-ref by definition
|
||||
source: "ci"
|
||||
}
|
||||
}
|
||||
|
||||
export function fromLocalGit(repoRoot = REPO_ROOT, execFn = tryExec) {
|
||||
const sha = execFn("git rev-parse HEAD", { cwd: repoRoot })
|
||||
if (!sha) return null
|
||||
const branch = execFn("git rev-parse --abbrev-ref HEAD", { cwd: repoRoot })
|
||||
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
|
||||
// We exclude untracked files (-uno) intentionally: a developer who's
|
||||
// checked out an installer scratch dir alongside the repo shouldn't
|
||||
// poison every local build with a [DIRTY] stamp. We DO care about
|
||||
// tracked-but-modified files because those mean the .exe content
|
||||
// differs from the commit being pinned.
|
||||
const status = execFn("git status --porcelain -uno", { cwd: repoRoot })
|
||||
const dirty = status !== null && status.length > 0
|
||||
return {
|
||||
commit: sha,
|
||||
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
|
||||
dirty: dirty,
|
||||
source: "local"
|
||||
}
|
||||
}
|
||||
|
||||
export function fromFallback(branch = FALLBACK_BRANCH) {
|
||||
// Non-git builds (ZIP download, bootstrap installer without a resolvable
|
||||
// HEAD) cannot determine a real commit. Use a placeholder so local /
|
||||
// personal builds can still complete. The desktop bootstrap treats the
|
||||
// all-zero commit as "unknown" and falls back to an unpinned branch
|
||||
// bootstrap instead of trying to fetch a non-existent GitHub commit.
|
||||
return {
|
||||
commit: FALLBACK_COMMIT,
|
||||
branch: branch || FALLBACK_BRANCH,
|
||||
dirty: false,
|
||||
source: "fallback"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the install stamp without writing it. Pure enough for unit tests:
|
||||
* inject env / execFn / repoRoot to simulate CI, local git, or no-git trees.
|
||||
*/
|
||||
export function resolveStamp({
|
||||
env = process.env,
|
||||
repoRoot = REPO_ROOT,
|
||||
execFn = tryExec,
|
||||
fallbackBranch = FALLBACK_BRANCH
|
||||
} = {}) {
|
||||
return fromCI(env) || fromLocalGit(repoRoot, execFn) || fromFallback(fallbackBranch)
|
||||
}
|
||||
|
||||
export function isFallbackCommit(commit) {
|
||||
return typeof commit === "string" && /^0{7,40}$/.test(commit)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const stamp = resolveStamp()
|
||||
if (!stamp || !stamp.commit) {
|
||||
// Should not happen — fromFallback() always provides a commit.
|
||||
console.error(
|
||||
"[write-build-stamp] ERROR: could not determine git commit.\n" +
|
||||
" - $GITHUB_SHA not set\n" +
|
||||
" - `git rev-parse HEAD` failed at " +
|
||||
REPO_ROOT +
|
||||
"\n" +
|
||||
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
|
||||
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (isFallbackCommit(stamp.commit)) {
|
||||
console.warn(
|
||||
"[write-build-stamp] WARNING: no git commit found (non-git checkout?).\n" +
|
||||
" Using placeholder commit — the packaged app will fall back to the\n" +
|
||||
" default branch for first-launch bootstrap. For production builds,\n" +
|
||||
" run from a git checkout or set $GITHUB_SHA."
|
||||
)
|
||||
}
|
||||
|
||||
if (stamp.dirty) {
|
||||
console.warn(
|
||||
"[write-build-stamp] WARNING: working tree is dirty.\n" +
|
||||
" Pinning to " +
|
||||
stamp.commit.slice(0, 12) +
|
||||
" but the packaged code may differ from that commit.\n" +
|
||||
" Commit your changes before publishing this build."
|
||||
)
|
||||
}
|
||||
|
||||
const payload = {
|
||||
schemaVersion: STAMP_SCHEMA_VERSION,
|
||||
commit: stamp.commit,
|
||||
branch: stamp.branch,
|
||||
builtAt: new Date().toISOString(),
|
||||
dirty: stamp.dirty,
|
||||
source: stamp.source
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
|
||||
console.log(
|
||||
"[write-build-stamp] wrote " +
|
||||
relative(REPO_ROOT, OUT_FILE) +
|
||||
" -> " +
|
||||
stamp.commit.slice(0, 12) +
|
||||
(stamp.branch ? " (" + stamp.branch + ")" : "") +
|
||||
(stamp.dirty ? " [DIRTY]" : "") +
|
||||
(stamp.source === "fallback" ? " [FALLBACK]" : "")
|
||||
)
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
main()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
FALLBACK_BRANCH,
|
||||
FALLBACK_COMMIT,
|
||||
fromCI,
|
||||
fromFallback,
|
||||
fromLocalGit,
|
||||
isFallbackCommit,
|
||||
resolveStamp
|
||||
} from './write-build-stamp.mjs'
|
||||
|
||||
test('fromCI reads GITHUB_SHA / GITHUB_REF_NAME', () => {
|
||||
assert.deepEqual(
|
||||
fromCI({ GITHUB_SHA: 'a'.repeat(40), GITHUB_REF_NAME: 'release' }),
|
||||
{ commit: 'a'.repeat(40), branch: 'release', dirty: false, source: 'ci' }
|
||||
)
|
||||
assert.equal(fromCI({}), null)
|
||||
})
|
||||
|
||||
test('fromLocalGit returns null when git rev-parse fails', () => {
|
||||
const stamp = fromLocalGit('/tmp/not-a-repo', () => null)
|
||||
assert.equal(stamp, null)
|
||||
})
|
||||
|
||||
test('fromLocalGit reads HEAD + branch + dirty status', () => {
|
||||
const calls = []
|
||||
const execFn = (cmd) => {
|
||||
calls.push(cmd)
|
||||
if (cmd === 'git rev-parse HEAD') return 'b'.repeat(40)
|
||||
if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main'
|
||||
if (cmd === 'git status --porcelain -uno') return ' M apps/desktop/package.json'
|
||||
return null
|
||||
}
|
||||
assert.deepEqual(fromLocalGit('/repo', execFn), {
|
||||
commit: 'b'.repeat(40),
|
||||
branch: 'main',
|
||||
dirty: true,
|
||||
source: 'local'
|
||||
})
|
||||
assert.ok(calls.includes('git rev-parse HEAD'))
|
||||
})
|
||||
|
||||
test('fromFallback uses the all-zero placeholder commit', () => {
|
||||
assert.deepEqual(fromFallback(), {
|
||||
commit: FALLBACK_COMMIT,
|
||||
branch: FALLBACK_BRANCH,
|
||||
dirty: false,
|
||||
source: 'fallback'
|
||||
})
|
||||
assert.equal(isFallbackCommit(FALLBACK_COMMIT), true)
|
||||
assert.equal(isFallbackCommit('a'.repeat(40)), false)
|
||||
})
|
||||
|
||||
test('resolveStamp prefers CI over local git over fallback', () => {
|
||||
const ci = resolveStamp({
|
||||
env: { GITHUB_SHA: 'c'.repeat(40), GITHUB_REF_NAME: 'main' },
|
||||
execFn: () => 'should-not-run'
|
||||
})
|
||||
assert.equal(ci.source, 'ci')
|
||||
assert.equal(ci.commit, 'c'.repeat(40))
|
||||
|
||||
const local = resolveStamp({
|
||||
env: {},
|
||||
execFn: (cmd) => {
|
||||
if (cmd === 'git rev-parse HEAD') return 'd'.repeat(40)
|
||||
if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main'
|
||||
if (cmd === 'git status --porcelain -uno') return ''
|
||||
return null
|
||||
}
|
||||
})
|
||||
assert.equal(local.source, 'local')
|
||||
assert.equal(local.commit, 'd'.repeat(40))
|
||||
assert.equal(local.dirty, false)
|
||||
})
|
||||
|
||||
test('resolveStamp falls back when neither CI nor git is available', () => {
|
||||
const stamp = resolveStamp({ env: {}, execFn: () => null })
|
||||
assert.deepEqual(stamp, {
|
||||
commit: FALLBACK_COMMIT,
|
||||
branch: FALLBACK_BRANCH,
|
||||
dirty: false,
|
||||
source: 'fallback'
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user