test(desktop): fail the pack when the bundled readiness parser goes stale
afterPack now verifies the packaged dist/electron-main.mjs still carries the dual-token readiness matcher before any platform work, turning source/packaged-artifact skew (#60772) into a build failure instead of a user-side boot loop. Co-authored-by: embwl0x <embwl0x@users.noreply.github.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import fs from 'node:fs'
|
||||
import { mkdir, readdir } from 'node:fs/promises'
|
||||
import { runPython } from '../../../scripts/build/python.mjs'
|
||||
|
||||
import { assertPackagedBackendReadyArtifact, resolvePackagedAsarPath } from './backend-ready-artifact.mjs'
|
||||
import { batchSignAppTree } from './batch-sign-binaries.mjs'
|
||||
import { rehashPayloadDigests } from './payload-digests.mjs'
|
||||
import { resolveSigningIdentity, signNestedChromium } from './sign-nested-chromium.mjs'
|
||||
@@ -48,6 +49,14 @@ export async function restoreMacLocaleMarkers({ appOutDir, packager }) {
|
||||
|
||||
export default async function afterPack(context) {
|
||||
const platform = context.electronPlatformName
|
||||
// Artifact-skew guard (#60772): before any platform work, prove the packed
|
||||
// bundle's readiness parser still accepts both ready tokens. This runs for
|
||||
// every packed build — first install, `hermes desktop`, the installer's
|
||||
// --update rebuild — so a stale matcher fails the pack here instead of
|
||||
// killing healthy backends on user machines.
|
||||
const asarPath = resolvePackagedAsarPath(context)
|
||||
assertPackagedBackendReadyArtifact(asarPath)
|
||||
console.log(`[after-pack] verified backend readiness parser in ${asarPath}`)
|
||||
const resources = platform === 'darwin'
|
||||
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources')
|
||||
: path.join(context.appOutDir, 'resources')
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
assertPackagedBackendReadyArtifact,
|
||||
assertBackendReadyArtifactSourceAcceptsBothTokens,
|
||||
resolvePackagedAsarPath
|
||||
} from './backend-ready-artifact.mjs'
|
||||
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import os from 'node:os'
|
||||
@@ -15,6 +20,20 @@ async function configuredHook(context) {
|
||||
await hook.default(context)
|
||||
}
|
||||
|
||||
// The afterPack readiness guard reads the packaged bundle's unpacked main;
|
||||
// every fixture here packs a valid dual-token matcher so the tests keep
|
||||
// exercising the locale/signing paths the hook also performs.
|
||||
async function seedPackagedMain(context) {
|
||||
const asarPath = resolvePackagedAsarPath(context)
|
||||
await mkdir(path.dirname(asarPath), { recursive: true })
|
||||
await writeFile(asarPath, 'stub archive')
|
||||
await mkdir(path.join(`${asarPath}.unpacked`, 'dist'), { recursive: true })
|
||||
await writeFile(
|
||||
path.join(`${asarPath}.unpacked`, 'dist', 'electron-main.mjs'),
|
||||
'const re = /HERMES_(?:BACKEND|DASHBOARD)_READY[^\\n]*port=(\\d+)/m\n'
|
||||
)
|
||||
}
|
||||
|
||||
function context(appOutDir, productFilename = 'Hermes Preview') {
|
||||
// Use electron-builder's real bundle path resolution, including branding.
|
||||
const packager = Object.assign(Object.create(PlatformPackager.prototype), {
|
||||
@@ -29,6 +48,7 @@ it('restores app localizations from the filtered framework without copying local
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'hermes-locale-pack-'))
|
||||
try {
|
||||
const ctx = context(root)
|
||||
await seedPackagedMain(ctx)
|
||||
const framework = ctx.packager.getMacOsElectronFrameworkResourcesDir(root)
|
||||
const resources = ctx.packager.getResourcesDir(root)
|
||||
await mkdir(resources, { recursive: true })
|
||||
@@ -40,7 +60,8 @@ it('restores app localizations from the filtered framework without copying local
|
||||
await mkdir(path.join(framework, 'other'), { recursive: true })
|
||||
await configuredHook(ctx)
|
||||
await configuredHook(ctx)
|
||||
expect((await readdir(resources)).sort()).toEqual(['en_GB.lproj', 'nb.lproj'])
|
||||
expect((await readdir(resources)).filter(name => name.endsWith('.lproj')).sort())
|
||||
.toEqual(['en_GB.lproj', 'nb.lproj'])
|
||||
expect(await readdir(path.join(resources, 'nb.lproj'))).toEqual([])
|
||||
expect(await readFile(path.join(framework, 'nb.lproj', 'locale.pak'), 'utf8')).toBe('untouched locale data')
|
||||
} finally {
|
||||
@@ -53,12 +74,15 @@ it('leaves Linux alone and reports a missing framework without failing packaging
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
// win32 is not a no-op here: the same hook sanitizes and batch-signs the PE tree.
|
||||
await configuredHook({ appOutDir: root, electronPlatformName: 'linux' })
|
||||
expect(await readdir(root)).toEqual([])
|
||||
const linuxCtx = { appOutDir: root, electronPlatformName: 'linux' }
|
||||
await seedPackagedMain(linuxCtx)
|
||||
await configuredHook(linuxCtx)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
await configuredHook(context(root))
|
||||
const ctx = context(root)
|
||||
await seedPackagedMain(ctx)
|
||||
await configuredHook(ctx)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('macOS locale markers were not restored'))
|
||||
expect(await readdir(root)).toEqual([])
|
||||
expect((await readdir(root)).sort()).toEqual(['Hermes Preview.app', 'resources'])
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
|
||||
116
apps/desktop/scripts/backend-ready-artifact.mjs
Normal file
116
apps/desktop/scripts/backend-ready-artifact.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Guard the packaged backend-readiness parser against artifact skew (#60772).
|
||||
*
|
||||
* The packaged app's `dist/electron-main.mjs` bundle must accept BOTH ready
|
||||
* tokens (`HERMES_BACKEND_READY` from a current backend, and the legacy
|
||||
* `HERMES_DASHBOARD_READY` from older ones). A stale artifact that matches
|
||||
* only one token boots a perfectly healthy backend and then kills it after
|
||||
* "Timed out waiting for Hermes backend port announcement" — invisible to
|
||||
* every source-level test, because the packaged bundle is the only thing that
|
||||
* ships. afterPack runs for every packed build, so this turns that class of
|
||||
* skew into a build failure instead of a user-side boot loop.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const PACKAGED_MAIN_MODULE = 'dist/electron-main.mjs'
|
||||
const READY_TOKENS = ['HERMES_BACKEND_READY', 'HERMES_DASHBOARD_READY']
|
||||
// The esbuild-bundled regex source for the dual-token readiness matcher. The
|
||||
// bundle keeps the source literal verbatim, so the packaged text contains the
|
||||
// (unexecuted) source form `HERMES_(?:BACKEND|DASHBOARD)_READY`; the parens
|
||||
// and `?` are escaped here to match that substring as text.
|
||||
const READY_MATCHER_SOURCE = /HERMES_\(\?:BACKEND\|DASHBOARD\)_READY/
|
||||
|
||||
function resolvePackagedAsarPath(context) {
|
||||
const appOutDir = context?.appOutDir
|
||||
if (!appOutDir || typeof appOutDir !== 'string') {
|
||||
throw new Error('electron-builder afterPack context is missing appOutDir')
|
||||
}
|
||||
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
if (appOutDir.endsWith('.app')) {
|
||||
return path.join(appOutDir, 'Contents', 'Resources', 'app.asar')
|
||||
}
|
||||
const productName = context.packager?.appInfo?.productFilename || 'Hermes'
|
||||
return path.join(appOutDir, `${productName}.app`, 'Contents', 'Resources', 'app.asar')
|
||||
}
|
||||
|
||||
return path.join(appOutDir, 'resources', 'app.asar')
|
||||
}
|
||||
|
||||
function loadAsarModule() {
|
||||
try {
|
||||
return require('@electron/asar')
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Cannot inspect packaged app.asar because @electron/asar is unavailable: ${err.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function unpackedPathForAsar(asarPath) {
|
||||
return path.join(`${asarPath}.unpacked`, PACKAGED_MAIN_MODULE)
|
||||
}
|
||||
|
||||
function extractPackagedMainSource(asarPath, options = {}) {
|
||||
if (!fs.existsSync(asarPath)) {
|
||||
throw new Error(`Missing packaged app.asar: ${asarPath}`)
|
||||
}
|
||||
|
||||
const unpackedPath = unpackedPathForAsar(asarPath)
|
||||
if (fs.existsSync(unpackedPath)) {
|
||||
return fs.readFileSync(unpackedPath, 'utf8')
|
||||
}
|
||||
|
||||
const asarModule = options.asarModule ?? loadAsarModule()
|
||||
if (!asarModule || typeof asarModule.extractFile !== 'function') {
|
||||
throw new Error('@electron/asar module does not expose extractFile')
|
||||
}
|
||||
|
||||
let source
|
||||
try {
|
||||
source = asarModule.extractFile(asarPath, PACKAGED_MAIN_MODULE)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Could not extract ${PACKAGED_MAIN_MODULE} from ${asarPath}: ${err.message}`
|
||||
)
|
||||
}
|
||||
|
||||
return Buffer.isBuffer(source) ? source.toString('utf8') : String(source)
|
||||
}
|
||||
|
||||
function assertBackendReadyArtifactSourceAcceptsBothTokens(
|
||||
source,
|
||||
label = PACKAGED_MAIN_MODULE
|
||||
) {
|
||||
if (!READY_MATCHER_SOURCE.test(source)) {
|
||||
throw new Error(
|
||||
`${label} does not contain a packaged readiness matcher accepting ` +
|
||||
READY_TOKENS.join(' and ')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackagedBackendReadyArtifact(asarPath, options = {}) {
|
||||
const source = extractPackagedMainSource(asarPath, options)
|
||||
assertBackendReadyArtifactSourceAcceptsBothTokens(source, PACKAGED_MAIN_MODULE)
|
||||
return {
|
||||
asarPath,
|
||||
module: PACKAGED_MAIN_MODULE,
|
||||
tokens: READY_TOKENS.slice()
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
PACKAGED_MAIN_MODULE,
|
||||
READY_TOKENS,
|
||||
assertBackendReadyArtifactSourceAcceptsBothTokens,
|
||||
assertPackagedBackendReadyArtifact,
|
||||
extractPackagedMainSource,
|
||||
resolvePackagedAsarPath,
|
||||
unpackedPathForAsar
|
||||
}
|
||||
110
apps/desktop/scripts/backend-ready-artifact.test.mjs
Normal file
110
apps/desktop/scripts/backend-ready-artifact.test.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
PACKAGED_MAIN_MODULE,
|
||||
READY_TOKENS,
|
||||
assertBackendReadyArtifactSourceAcceptsBothTokens,
|
||||
assertPackagedBackendReadyArtifact,
|
||||
extractPackagedMainSource,
|
||||
resolvePackagedAsarPath
|
||||
} from './backend-ready-artifact.mjs'
|
||||
|
||||
const CURRENT_SOURCE = 'const re = /HERMES_(?:BACKEND|DASHBOARD)_READY[^\\n]*port=(\\d+)/m\n'
|
||||
// The pre-#55923 matcher: only the legacy token. A packaged bundle carrying
|
||||
// this kills a current backend after the port-announcement timeout (#60772).
|
||||
const STALE_SOURCE = 'const re = /HERMES_DASHBOARD_READY port=(\\d+)/m\n'
|
||||
|
||||
async function packedAppRoot() {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'hermes-ready-artifact-'))
|
||||
const resources = path.join(root, 'resources')
|
||||
await mkdir(resources, { recursive: true })
|
||||
return { root, resources }
|
||||
}
|
||||
|
||||
async function writeAsar(resources, source) {
|
||||
// app.asar is an archive we cannot write by hand; the guard reads the
|
||||
// .unpacked mirror first, and electron-builder always lays the bundled
|
||||
// main out there when asarUnpack applies — and the test controls which
|
||||
// path the guard takes, so exercising the unpacked mirror is the real
|
||||
// flow for the asarUnpack-eligible bundle.
|
||||
const unpacked = path.join(resources, 'app.asar.unpacked', 'dist')
|
||||
await mkdir(unpacked, { recursive: true })
|
||||
await writeFile(path.join(unpacked, 'electron-main.mjs'), source)
|
||||
await writeFile(path.join(resources, 'app.asar'), 'stub archive')
|
||||
return path.join(resources, 'app.asar')
|
||||
}
|
||||
|
||||
it('resolves the asar path per platform, including the branded macOS bundle', () => {
|
||||
expect(resolvePackagedAsarPath({ appOutDir: '/out/win', electronPlatformName: 'win32' }))
|
||||
.toBe(path.join('/out/win', 'resources', 'app.asar'))
|
||||
expect(resolvePackagedAsarPath({
|
||||
appOutDir: '/out/mac',
|
||||
electronPlatformName: 'darwin',
|
||||
packager: { appInfo: { productFilename: 'Hermes Preview' } }
|
||||
})).toBe(path.join('/out/mac', 'Hermes Preview.app', 'Contents', 'Resources', 'app.asar'))
|
||||
expect(() => resolvePackagedAsarPath({ electronPlatformName: 'linux' }))
|
||||
.toThrow('missing appOutDir')
|
||||
})
|
||||
|
||||
it('accepts a packaged bundle whose matcher handles both tokens', async () => {
|
||||
const { resources } = await packedAppRoot()
|
||||
try {
|
||||
const asar = await writeAsar(resources, CURRENT_SOURCE)
|
||||
const result = assertPackagedBackendReadyArtifact(asar)
|
||||
expect(result.module).toBe(PACKAGED_MAIN_MODULE)
|
||||
expect(result.tokens).toEqual(READY_TOKENS)
|
||||
} finally {
|
||||
await rm(resources, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects the stale dashboard-only matcher — the #60772 artifact skew', async () => {
|
||||
const { resources } = await packedAppRoot()
|
||||
try {
|
||||
const asar = await writeAsar(resources, STALE_SOURCE)
|
||||
expect(() => assertPackagedBackendReadyArtifact(asar)).toThrow(
|
||||
'does not contain a packaged readiness matcher accepting HERMES_BACKEND_READY and HERMES_DASHBOARD_READY'
|
||||
)
|
||||
} finally {
|
||||
await rm(resources, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails the pack when the packaged app.asar is missing', async () => {
|
||||
const { resources } = await packedAppRoot()
|
||||
try {
|
||||
expect(() => assertPackagedBackendReadyArtifact(path.join(resources, 'app.asar')))
|
||||
.toThrow('Missing packaged app.asar')
|
||||
} finally {
|
||||
await rm(resources, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('extracts through @electron/asar when no unpacked mirror exists', async () => {
|
||||
const { resources } = await packedAppRoot()
|
||||
try {
|
||||
const asar = path.join(resources, 'app.asar')
|
||||
await writeFile(asar, 'stub archive')
|
||||
const extracted = vi.fn(() => Buffer.from(CURRENT_SOURCE, 'utf8'))
|
||||
const source = extractPackagedMainSource(asar, { asarModule: { extractFile: extracted } })
|
||||
expect(source).toBe(CURRENT_SOURCE)
|
||||
expect(extracted).toHaveBeenCalledWith(asar, PACKAGED_MAIN_MODULE)
|
||||
expect(() => assertPackagedBackendReadyArtifact(asar, { asarModule: { extractFile: () => Buffer.from(STALE_SOURCE) } }))
|
||||
.toThrow('readiness matcher')
|
||||
} finally {
|
||||
await rm(resources, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('the CURRENT source-tree parser itself passes the guard', async () => {
|
||||
// The shipped parser (electron/backend-ready.ts) must satisfy the very
|
||||
// property the packaged bundle is asserted to keep.
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const backendReady = await readFile(
|
||||
path.resolve(import.meta.dirname, '..', 'electron', 'backend-ready.ts'), 'utf8')
|
||||
expect(() => assertBackendReadyArtifactSourceAcceptsBothTokens(backendReady, 'backend-ready.ts'))
|
||||
.not.toThrow()
|
||||
})
|
||||
Reference in New Issue
Block a user