8e79ec4942
- 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.
190 lines
6.2 KiB
JavaScript
190 lines
6.2 KiB
JavaScript
#!/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)))
|