feat: enhance desktop packaging process with self-contained harness verification
- Updated README and README.zh to clarify packaging commands and added verification step. - Improved packaging scripts to automatically build the self-contained runtime and verify its integrity. - Introduced new scripts for verifying harness self-containment and booting the web profile. - Added comprehensive tests for relinking harness symlinks and ensuring self-containment. - Updated package.json scripts to reflect new build and verification processes.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify a harness tree is self-contained and actually boots from the bundle.
|
||||
*
|
||||
* Default: check the assembled `build/harness`. With `--app <dir>`, locate the
|
||||
* `resources/harness` inside a packed/unpacked app instead — this additionally
|
||||
* proves electron-builder's `extraResources` copy preserved the relative
|
||||
* in-tree links. `--boot` also spawns the web profile straight from the bundle
|
||||
* (temp DSH_HOME, telemetry off) and waits for its readiness line, proving the
|
||||
* relinked node_modules resolves without the repository present.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-harness.mjs # build/harness
|
||||
* node scripts/verify-harness.mjs --boot # + web boot smoke
|
||||
* node scripts/verify-harness.mjs --app dist # a packed app's harness
|
||||
* node scripts/verify-harness.mjs --app dist --boot
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { assertSelfContained } from './harness-relink.mjs'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
|
||||
const desktopDir = join(root, 'apps/desktop')
|
||||
const defaultHarness = join(desktopDir, 'build/harness')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const boot = args.includes('--boot')
|
||||
const appIndex = args.indexOf('--app')
|
||||
const appValue = args.find((a) => a.startsWith('--app='))?.slice('--app='.length) ?? (appIndex !== -1 ? args[appIndex + 1] : undefined)
|
||||
const appRoot = appValue === undefined
|
||||
? undefined
|
||||
: isAbsolute(appValue)
|
||||
? resolve(appValue)
|
||||
: resolve(desktopDir, appValue)
|
||||
|
||||
const timeoutMs = 60_000
|
||||
const READY_RE = /http:\/\/127\.0\.0\.1:(\d+)/
|
||||
|
||||
function fail(message) {
|
||||
console.error(`verify-harness: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/** Find every `resources/harness` under an unpacked electron-builder app. */
|
||||
function findHarnessUnder(appDir) {
|
||||
const found = []
|
||||
// electron-builder unpacked layout:
|
||||
// win: <root>/win-unpacked/resources/harness
|
||||
// mac: <root>/mac(-arch)/<Name>.app/Contents/Resources/harness
|
||||
const visit = (dir, depth) => {
|
||||
if (depth > 5) return
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const p = join(dir, entry.name)
|
||||
if (basename(p) === 'harness' && basename(dirname(p)) === 'resources') found.push(p)
|
||||
if (entry.isDirectory()) visit(p, depth + 1)
|
||||
}
|
||||
}
|
||||
visit(appDir, 0)
|
||||
return found
|
||||
}
|
||||
|
||||
function nodeBinary(harness) {
|
||||
return join(harness, 'bin', process.platform === 'win32' ? 'node.exe' : 'node')
|
||||
}
|
||||
|
||||
function cliEntry(harness) {
|
||||
return join(harness, 'apps/cli/lib/bin.js')
|
||||
}
|
||||
|
||||
/** Run the bundled CLI's `--version` straight from the bundle. */
|
||||
function checkVersion(harness) {
|
||||
const result = spawnSync(nodeBinary(harness), [cliEntry(harness), '--version'], {
|
||||
cwd: harness,
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
if (result.error !== undefined || result.status !== 0) {
|
||||
fail(`the bundled CLI failed to run: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${String(result.status)}`}`)
|
||||
}
|
||||
console.log(` dsh --version: ${result.stdout?.trim()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the web profile from the bundle and resolve once it prints its
|
||||
* readiness URL. Hermetic: a temp DSH_HOME and telemetry off, so the check
|
||||
* neither touches the real profile nor the repository.
|
||||
*/
|
||||
function bootWeb(harness) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-verify-home-'))
|
||||
const child = spawn(
|
||||
nodeBinary(harness),
|
||||
[cliEntry(harness), '--profile', 'web', '--host', '127.0.0.1', '--port', '0'],
|
||||
{
|
||||
cwd: harness,
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
env: { ...process.env, DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DSH_ALLOW_PLUGIN_INSTALL: '1' },
|
||||
},
|
||||
)
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
let settled = false
|
||||
let buffered = ''
|
||||
const cleanup = () => {
|
||||
try {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Best effort; a locked file on Windows is not fatal for a verify tool.
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
child.kill()
|
||||
cleanup()
|
||||
reject(new Error(`web boot timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
if (settled) return
|
||||
buffered += chunk
|
||||
if (READY_RE.test(buffered)) {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
child.kill()
|
||||
cleanup()
|
||||
resolvePromise()
|
||||
}
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
cleanup()
|
||||
reject(new Error(`harness exited before serving (code ${String(code)})`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const harness = (() => {
|
||||
if (appRoot !== undefined) {
|
||||
const found = findHarnessUnder(appRoot)
|
||||
if (found.length === 0) fail(`no resources/harness found under ${appRoot}`)
|
||||
if (found.length > 1) {
|
||||
console.warn(`verify-harness: ${found.length} harness dirs found; checking the first:\n ${found.join('\n ')}`)
|
||||
}
|
||||
return found[0]
|
||||
}
|
||||
if (!existsSync(defaultHarness)) fail(`build/harness not found at ${defaultHarness} — run build:harness first`)
|
||||
return defaultHarness
|
||||
})()
|
||||
|
||||
console.log(`verify-harness: ${harness}`)
|
||||
if (!existsSync(nodeBinary(harness))) fail(`bundled node not found at ${nodeBinary(harness)}`)
|
||||
try {
|
||||
assertSelfContained(harness)
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
console.log(' links: self-contained (no link escapes the bundle)')
|
||||
checkVersion(harness)
|
||||
if (boot) {
|
||||
try {
|
||||
await bootWeb(harness)
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
console.log(' web boot: ok')
|
||||
}
|
||||
console.log('verify-harness: ok')
|
||||
}
|
||||
|
||||
main().catch((error) => fail(error instanceof Error ? error.message : String(error)))
|
||||
Reference in New Issue
Block a user