mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
ab43432bb2
Standalone Node layouts (SEA and similar) cannot rely on package-relative paths for the native library, tree-sitter assets, and parser worker. Resolve them by stable keys under OTUI_ASSET_ROOT, split Bun/Node asset outputs, and expose a Node asset manifest for packaging and out-of-tree SEA acceptance tests.
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
import { spawnSync, type SpawnSyncReturns } from "node:child_process"
|
|
import { createHash } from "node:crypto"
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { dirname, join, relative, resolve } from "node:path"
|
|
import process from "node:process"
|
|
import { fileURLToPath } from "node:url"
|
|
|
|
import { requireNode26 } from "../../../scripts/node26.mjs"
|
|
|
|
interface PackageJson {
|
|
name: string
|
|
version: string
|
|
}
|
|
|
|
interface NodeAsset {
|
|
readonly key: string
|
|
readonly source: string
|
|
}
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
|
const rootDir = resolve(scriptDir, "..")
|
|
const distDir = join(rootDir, "dist")
|
|
const args = new Set(process.argv.slice(2))
|
|
const keepTemp = args.has("--keep-temp")
|
|
const skipBuild = args.has("--skip-build")
|
|
const nodePath = requireNode26()
|
|
const packageJson = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")) as PackageJson
|
|
const nativePackageName = `${packageJson.name}-${process.platform}-${process.arch}`
|
|
const nativePackageDir = join(rootDir, "node_modules", nativePackageName)
|
|
|
|
function run(
|
|
command: string,
|
|
commandArgs: string[],
|
|
cwd: string,
|
|
errorMessage: string,
|
|
options: { env?: NodeJS.ProcessEnv; pipe?: boolean; timeout?: number } = {},
|
|
): SpawnSyncReturns<Buffer> {
|
|
const result = spawnSync(command, commandArgs, {
|
|
cwd,
|
|
env: options.env ?? process.env,
|
|
stdio: options.pipe ? "pipe" : "inherit",
|
|
timeout: options.timeout,
|
|
})
|
|
if (result.error) {
|
|
throw new Error(`${errorMessage}: ${result.error.message}`)
|
|
}
|
|
if (result.status !== 0) {
|
|
const output = options.pipe ? `\n${result.stdout.toString()}\n${result.stderr.toString()}` : ""
|
|
throw new Error(`${errorMessage}${output}`)
|
|
}
|
|
return result
|
|
}
|
|
|
|
function packArtifact(packageDir: string, packDir: string): string {
|
|
const result = run("npm", ["pack", "--pack-destination", packDir], packageDir, `Failed to pack ${packageDir}`, {
|
|
pipe: true,
|
|
})
|
|
const name = result.stdout.toString("utf8").trim().split(/\r?\n/).at(-1)
|
|
if (!name) throw new Error(`Failed to determine tarball name for ${packageDir}`)
|
|
return join(packDir, name)
|
|
}
|
|
|
|
function writeConsumerPackage(consumerDir: string, coreTarball: string, nativeTarball: string): void {
|
|
writeFileSync(
|
|
join(consumerDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "opentui-core-sea-test",
|
|
private: true,
|
|
type: "module",
|
|
dependencies: {
|
|
[packageJson.name]: `file:${relative(consumerDir, coreTarball).replaceAll("\\", "/")}`,
|
|
[nativePackageName]: `file:${relative(consumerDir, nativeTarball).replaceAll("\\", "/")}`,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
}
|
|
|
|
function collectManifest(consumerDir: string): NodeAsset[] {
|
|
const manifestPath = join(consumerDir, "manifest.json")
|
|
writeFileSync(
|
|
join(consumerDir, "manifest.mjs"),
|
|
`import { writeFileSync } from "node:fs"
|
|
import { getNodeAssets } from ${JSON.stringify(`${packageJson.name}/node-assets`)}
|
|
|
|
const assets = getNodeAssets({
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
...(process.platform === "linux" ? { libc: "glibc" } : {}),
|
|
})
|
|
writeFileSync(${JSON.stringify(manifestPath)}, JSON.stringify(assets))
|
|
`,
|
|
)
|
|
run(nodePath, ["manifest.mjs"], consumerDir, "Failed to collect OpenTUI Node assets")
|
|
return JSON.parse(readFileSync(manifestPath, "utf8")) as NodeAsset[]
|
|
}
|
|
|
|
function writeApplication(consumerDir: string): void {
|
|
writeFileSync(
|
|
join(consumerDir, "app.mjs"),
|
|
`import assert from "node:assert/strict"
|
|
import { mkdtempSync, rmSync } from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { isAbsolute, join } from "node:path"
|
|
import { isSea } from "node:sea"
|
|
import { OptimizedBuffer, TreeSitterClient } from ${JSON.stringify(packageJson.name)}
|
|
|
|
assert.equal(isSea(), true)
|
|
assert.ok(process.env.OTUI_ASSET_ROOT)
|
|
assert.equal(isAbsolute(process.env.OTUI_ASSET_ROOT), true)
|
|
|
|
const buffer = OptimizedBuffer.create(4, 2, "unicode")
|
|
assert.equal(buffer.width, 4)
|
|
buffer.destroy()
|
|
|
|
const dataPath = mkdtempSync(join(tmpdir(), "opentui-sea-tree-sitter-"))
|
|
const client = new TreeSitterClient({ dataPath })
|
|
try {
|
|
const result = await client.highlightOnce(${JSON.stringify("# SEA\n\n```js\nconst answer = 42\n```\n")}, "markdown")
|
|
assert.equal(result.error, undefined)
|
|
assert.ok(result.highlights?.length)
|
|
} finally {
|
|
await client.destroy()
|
|
rmSync(dataPath, { recursive: true, force: true })
|
|
}
|
|
|
|
console.log("OpenTUI Node SEA acceptance passed")
|
|
`,
|
|
)
|
|
}
|
|
|
|
function writeBunApplication(consumerDir: string): void {
|
|
writeFileSync(
|
|
join(consumerDir, "bun-app.ts"),
|
|
`import assert from "node:assert/strict"
|
|
import { mkdtempSync, rmSync } from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { join } from "node:path"
|
|
import { createTestRenderer } from ${JSON.stringify(`${packageJson.name}/testing`)}
|
|
import * as Yoga from ${JSON.stringify(`${packageJson.name}/yoga`)}
|
|
|
|
import { OptimizedBuffer, TreeSitterClient, Yoga as CoreYoga } from ${JSON.stringify(packageJson.name)}
|
|
|
|
assert.equal(typeof createTestRenderer, "function")
|
|
assert.equal(CoreYoga.Node, Yoga.Node)
|
|
const yogaNode = Yoga.Node.create()
|
|
yogaNode.free()
|
|
|
|
const buffer = OptimizedBuffer.create(4, 2, "unicode")
|
|
assert.equal(buffer.width, 4)
|
|
buffer.destroy()
|
|
|
|
const dataPath = mkdtempSync(join(tmpdir(), "opentui-bun-tree-sitter-"))
|
|
const client = new TreeSitterClient({ dataPath })
|
|
try {
|
|
const result = await client.highlightOnce(${JSON.stringify("# Bun\n\n```js\nconst answer = 42\n```\n")}, "markdown")
|
|
assert.equal(result.error, undefined)
|
|
assert.ok(result.highlights?.length)
|
|
} finally {
|
|
await client.destroy()
|
|
rmSync(dataPath, { recursive: true, force: true })
|
|
}
|
|
|
|
console.log("OpenTUI Bun executable acceptance passed")
|
|
`,
|
|
)
|
|
}
|
|
|
|
function assetHash(assets: readonly NodeAsset[]): string {
|
|
const hash = createHash("sha256")
|
|
for (const asset of assets) {
|
|
hash.update(asset.key)
|
|
hash.update(readFileSync(asset.source))
|
|
}
|
|
return hash.digest("hex").slice(0, 16)
|
|
}
|
|
|
|
function seaPrelude(hash: string): string {
|
|
return `import { existsSync as __otuiExists, mkdirSync as __otuiMkdir, writeFileSync as __otuiWrite } from "node:fs"
|
|
import { tmpdir as __otuiTmpdir } from "node:os"
|
|
import { isAbsolute as __otuiIsAbsolute, join as __otuiJoin, dirname as __otuiDirname } from "node:path"
|
|
import { getAssetKeys as __otuiAssetKeys, getRawAsset as __otuiRawAsset, isSea as __otuiIsSea } from "node:sea"
|
|
if (!__otuiIsSea()) throw new Error("Expected a Node SEA executable")
|
|
const __otuiRoot = __otuiJoin(__otuiTmpdir(), ${JSON.stringify(`opentui-core-sea-${hash}`)})
|
|
for (const __otuiKey of __otuiAssetKeys()) {
|
|
if (__otuiIsAbsolute(__otuiKey) || __otuiKey.includes("\\\\") || __otuiKey.split("/").includes("..")) {
|
|
throw new Error(\`Invalid SEA asset key: \${__otuiKey}\`)
|
|
}
|
|
const __otuiTarget = __otuiJoin(__otuiRoot, __otuiKey)
|
|
if (__otuiExists(__otuiTarget)) continue
|
|
__otuiMkdir(__otuiDirname(__otuiTarget), { recursive: true })
|
|
__otuiWrite(__otuiTarget, new Uint8Array(__otuiRawAsset(__otuiKey)))
|
|
}
|
|
process.env.OTUI_ASSET_ROOT = __otuiRoot
|
|
`
|
|
}
|
|
|
|
let temporaryRoot: string | undefined
|
|
|
|
try {
|
|
if (!skipBuild) {
|
|
run("bun", ["run", "build"], rootDir, "Standalone test build failed", { timeout: 20 * 60_000 })
|
|
}
|
|
if (!existsSync(distDir) || !existsSync(nativePackageDir)) {
|
|
throw new Error("Missing core or native build artifacts")
|
|
}
|
|
|
|
temporaryRoot = mkdtempSync(join(tmpdir(), "opentui-core-standalone-test-"))
|
|
const buildDir = join(temporaryRoot, "build")
|
|
const consumerDir = join(buildDir, "consumer")
|
|
const packDir = join(buildDir, "packs")
|
|
const deployDir = join(temporaryRoot, "deploy")
|
|
const workDir = join(deployDir, "work")
|
|
const dataDir = join(deployDir, "data")
|
|
mkdirSync(consumerDir, { recursive: true })
|
|
mkdirSync(packDir, { recursive: true })
|
|
mkdirSync(workDir, { recursive: true })
|
|
mkdirSync(dataDir, { recursive: true })
|
|
|
|
const coreTarball = packArtifact(distDir, packDir)
|
|
const nativeTarball = packArtifact(nativePackageDir, packDir)
|
|
writeConsumerPackage(consumerDir, coreTarball, nativeTarball)
|
|
run("npm", ["install", "--ignore-scripts", "--no-package-lock"], consumerDir, "SEA test install failed")
|
|
const assets = collectManifest(consumerDir)
|
|
writeApplication(consumerDir)
|
|
writeBunApplication(consumerDir)
|
|
const builtBunExecutable = join(buildDir, process.platform === "win32" ? "opentui-bun.exe" : "opentui-bun")
|
|
const bunCompileArgs = ["build", "--compile", `--outfile=${builtBunExecutable}`, join(consumerDir, "bun-app.ts")]
|
|
if (process.platform === "linux") {
|
|
bunCompileArgs.push(`--define=process.env.OPENTUI_LIBC=${JSON.stringify("glibc")}`)
|
|
}
|
|
run("bun", bunCompileArgs, consumerDir, "Bun executable build failed", { timeout: 10 * 60_000 })
|
|
const bundlePath = join(consumerDir, "bundle.mjs")
|
|
run(
|
|
"bun",
|
|
["build", "--target=node", `--outfile=${bundlePath}`, join(consumerDir, "app.mjs")],
|
|
consumerDir,
|
|
"SEA application bundle failed",
|
|
)
|
|
const bundle = readFileSync(bundlePath, "utf8")
|
|
if (/with:\s*\{\s*type:\s*["'](?:file|wasm)["']/.test(bundle)) {
|
|
throw new Error("SEA application bundle contains a Bun file import attribute")
|
|
}
|
|
if (/import\(["']@opentui\/core-(?:darwin|linux|win32)-/.test(bundle)) {
|
|
throw new Error("SEA application bundle contains a static OpenTUI native package import")
|
|
}
|
|
|
|
const hash = assetHash(assets)
|
|
const extractionRoot = join(dataDir, `opentui-core-sea-${hash}`)
|
|
rmSync(extractionRoot, { recursive: true, force: true })
|
|
const seaMain = join(consumerDir, "sea-main.mjs")
|
|
writeFileSync(seaMain, seaPrelude(hash) + bundle)
|
|
const builtExecutable = join(buildDir, process.platform === "win32" ? "opentui-sea.exe" : "opentui-sea")
|
|
writeFileSync(
|
|
join(consumerDir, "sea-config.json"),
|
|
JSON.stringify(
|
|
{
|
|
main: seaMain,
|
|
mainFormat: "module",
|
|
executable: nodePath,
|
|
output: builtExecutable,
|
|
disableExperimentalSEAWarning: true,
|
|
useSnapshot: false,
|
|
useCodeCache: false,
|
|
execArgv: ["--experimental-ffi", "--no-warnings"],
|
|
execArgvExtension: "none",
|
|
assets: Object.fromEntries(assets.map((asset) => [asset.key, asset.source])),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
run(nodePath, ["--build-sea", "sea-config.json"], consumerDir, "Node SEA build failed", { timeout: 10 * 60_000 })
|
|
|
|
const deployedExecutable = join(deployDir, process.platform === "win32" ? "opentui-sea.exe" : "opentui-sea")
|
|
const deployedBunExecutable = join(deployDir, process.platform === "win32" ? "opentui-bun.exe" : "opentui-bun")
|
|
copyFileSync(builtExecutable, deployedExecutable)
|
|
copyFileSync(builtBunExecutable, deployedBunExecutable)
|
|
if (process.platform !== "win32") chmodSync(deployedExecutable, 0o755)
|
|
if (process.platform !== "win32") chmodSync(deployedBunExecutable, 0o755)
|
|
rmSync(buildDir, { recursive: true, force: true })
|
|
|
|
const runtimeEnv = {
|
|
...process.env,
|
|
HOME: dataDir,
|
|
XDG_DATA_HOME: dataDir,
|
|
TMPDIR: dataDir,
|
|
TEMP: dataDir,
|
|
TMP: dataDir,
|
|
}
|
|
delete runtimeEnv.OTUI_ASSET_ROOT
|
|
delete runtimeEnv.OTUI_TREE_SITTER_WORKER_PATH
|
|
for (let runIndex = 0; runIndex < 2; runIndex++) {
|
|
const result = run(deployedExecutable, [], workDir, `Node SEA run ${runIndex + 1} failed`, {
|
|
env: runtimeEnv,
|
|
pipe: true,
|
|
timeout: 60_000,
|
|
})
|
|
if (!result.stdout.toString("utf8").includes("OpenTUI Node SEA acceptance passed")) {
|
|
throw new Error(`Node SEA run ${runIndex + 1} did not print the success marker`)
|
|
}
|
|
}
|
|
|
|
const bunResult = run(deployedBunExecutable, [], workDir, "Bun executable run failed", {
|
|
env: runtimeEnv,
|
|
pipe: true,
|
|
timeout: 60_000,
|
|
})
|
|
if (!bunResult.stdout.toString("utf8").includes("OpenTUI Bun executable acceptance passed")) {
|
|
throw new Error("Bun executable did not print the success marker")
|
|
}
|
|
|
|
if (!keepTemp) {
|
|
rmSync(temporaryRoot, { recursive: true, force: true })
|
|
temporaryRoot = undefined
|
|
}
|
|
console.log("Standalone executable tests passed")
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : error)
|
|
if (temporaryRoot) console.error(`Standalone test workspace kept at ${temporaryRoot}`)
|
|
process.exit(1)
|
|
}
|