diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 7e689bb11ab..6b2d06d3ba2 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -37,7 +37,10 @@ const { assertMobileWebBundleBuilt } = require('./scripts/verify-packaged-mobile-web-bundle.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') -const { assertPackagedWebClient } = require('./scripts/verify-packaged-web-client.cjs') +const { + assertPackagedServeSelectionEntry, + assertPackagedWebClient +} = require('./scripts/verify-packaged-serve-payload.cjs') const { assertBundledRipgrepInstalled, bundledRipgrepExtraResources, @@ -503,6 +506,7 @@ module.exports = { // mapping fails packaging before bundled content reaches users. verifyPackagedPluginResources(resourcesDir) assertPackagedWebClient(resourcesDir) + assertPackagedServeSelectionEntry(resourcesDir) finalizePackagedRipgrep(resourcesDir) await finalizePackagedOrcadTemplate(resourcesDir, { platform: context.electronPlatformName, diff --git a/config/scripts/electron-builder-runtime-resources.test.mjs b/config/scripts/electron-builder-runtime-resources.test.mjs index 1f827b7e754..021711ab574 100644 --- a/config/scripts/electron-builder-runtime-resources.test.mjs +++ b/config/scripts/electron-builder-runtime-resources.test.mjs @@ -411,7 +411,7 @@ describe('packaged runtime resources', () => { { recursive: true } ) await seedBundledRipgrep(resourcesDir) - await seedPackagedWebClient(resourcesDir) + await seedPackagedServePayload(resourcesDir) const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') await mkdir(unpackedMainDir, { recursive: true }) @@ -478,7 +478,7 @@ describe('packaged runtime resources', () => { { recursive: true } ) await seedBundledRipgrep(resourcesDir) - await seedPackagedWebClient(resourcesDir) + await seedPackagedServePayload(resourcesDir) // Layout fixtures use a cross-arch slice; native PTY probes need real packaged binaries. const target = process.arch === 'x64' @@ -556,7 +556,11 @@ async function stubObjdump(root) { process.env.PATH = `${directory}${delimiter}${process.env.PATH ?? ''}` } -async function seedPackagedWebClient(resourcesDir) { +async function seedPackagedServePayload(resourcesDir) { + const { SERVE_SELECTION_ENTRY } = require('./verify-packaged-serve-payload.cjs') + const entry = join(resourcesDir, 'app.asar.unpacked', 'out', ...SERVE_SELECTION_ENTRY.split('/')) + await mkdir(dirname(entry), { recursive: true }) + await writeFile(entry, '') const webDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'web') await mkdir(join(webDir, 'assets'), { recursive: true }) await mkdir(join(resourcesDir, 'app.asar.unpacked', 'out', 'cli'), { recursive: true }) diff --git a/config/scripts/verify-packaged-serve-payload.cjs b/config/scripts/verify-packaged-serve-payload.cjs new file mode 100644 index 00000000000..a60ec33a92a --- /dev/null +++ b/config/scripts/verify-packaged-serve-payload.cjs @@ -0,0 +1,102 @@ +const { existsSync, readFileSync, statSync } = require('node:fs') +const { dirname, join, posix } = require('node:path') + +const WEB_CLIENT_INDEX = 'web-index.html' +const REMEDY = 'Run pnpm build:web (build:desktop and build:release already do).' + +function failure(message) { + return new Error(`[verify-packaged-serve-payload] ${message}`) +} + +/** The relative files web-index.html loads; the page is useless if any is missing. */ +function referencedAssets(html) { + const assets = new Set() + for (const match of html.matchAll(/\b(?:src|href)=["']\.\/([^"'?#]+)/g)) { + assets.add(posix.normalize(match[1])) + } + return [...assets] +} + +/** + * Why: orcad serves the browser client by path from the CLI's own app root, and it runs on plain + * Node, which cannot read app.asar. So the page and everything it loads must be unpacked beside + * out/cli, or `orca serve` comes up with no web client (#30100). + */ +function assertPackagedWebClient(resourcesDir) { + const outDir = join(resourcesDir, 'app.asar.unpacked', 'out') + if (!existsSync(join(outDir, 'cli'))) { + throw failure(`no unpacked CLI at ${join(outDir, 'cli')}; the web client root resolves from it`) + } + const webRoot = join(outDir, 'web') + const indexPath = join(webRoot, WEB_CLIENT_INDEX) + if (!existsSync(indexPath)) { + throw failure(`missing unpacked ${indexPath}. ${REMEDY}`) + } + const assets = referencedAssets(readFileSync(indexPath, 'utf8')) + if (assets.length === 0) { + throw failure(`${indexPath} references no ./ assets. ${REMEDY}`) + } + const missing = assets.filter( + (asset) => asset.startsWith('..') || !existsSync(join(webRoot, ...asset.split('/'))) + ) + if (missing.length > 0) { + throw failure(`${indexPath} loads files missing from ${webRoot}: ${missing.join(', ')}`) + } + console.log(`[verify-packaged-serve-payload] OK — ${String(assets.length)} asset(s) unpacked`) +} + +// Relative to out/; mirrors ORCAD_LOCAL_SERVE_SELECTION_ENTRY, which a test pins. +const SERVE_SELECTION_ENTRY = 'main/orcad/orcad-local-serve-selection-entry.js' + +/** The file Node loads for a relative require, or null when none is unpacked. */ +function resolveRequiredFile(path) { + const candidates = [path, `${path}.js`, `${path}.json`, join(path, 'index.js')] + return ( + candidates.find((candidate) => statSync(candidate, { throwIfNoEntry: false })?.isFile()) ?? null + ) +} + +/** Relative requires reachable from `entry`, so a packed chunk fails here and not at serve time. */ +function missingRelativeRequires(entry) { + const missing = new Set() + const visited = new Set([entry]) + const queue = [entry] + while (queue.length > 0) { + const file = queue.shift() + for (const match of readFileSync(file, 'utf8').matchAll( + /\brequire\(["'](\.{1,2}\/[^"']+)["']\)/g + )) { + const required = join(dirname(file), match[1]) + const resolved = resolveRequiredFile(required) + if (!resolved) { + missing.add(required) + } else if (!visited.has(resolved)) { + visited.add(resolved) + queue.push(resolved) + } + } + } + return [...missing] +} + +/** + * Why: `orca serve` asks this entry which host to serve on, running it on the app's executable + * as plain Node, which cannot read app.asar. A packed copy made every serve fall back (#27404). + */ +function assertPackagedServeSelectionEntry(resourcesDir) { + const entry = join(resourcesDir, 'app.asar.unpacked', 'out', ...SERVE_SELECTION_ENTRY.split('/')) + if (!existsSync(entry)) { + throw failure(`missing unpacked ${entry}; orca serve runs it under ELECTRON_RUN_AS_NODE`) + } + const missing = missingRelativeRequires(entry) + if (missing.length > 0) { + throw failure(`${entry} requires files missing from the unpacked app: ${missing.join(', ')}`) + } + console.log('[verify-packaged-serve-payload] OK — serve-selection entry unpacked') +} + +module.exports = { + SERVE_SELECTION_ENTRY, + assertPackagedServeSelectionEntry, + assertPackagedWebClient +} diff --git a/config/scripts/verify-packaged-web-client.test.mjs b/config/scripts/verify-packaged-serve-payload.test.mjs similarity index 55% rename from config/scripts/verify-packaged-web-client.test.mjs rename to config/scripts/verify-packaged-serve-payload.test.mjs index eb69f0b274d..37bfe660df5 100644 --- a/config/scripts/verify-packaged-web-client.test.mjs +++ b/config/scripts/verify-packaged-serve-payload.test.mjs @@ -1,16 +1,21 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { ORCAD_LOCAL_SERVE_SELECTION_ENTRY } from '../../src/shared/orcad-local-serve-selection' const require = createRequire(import.meta.url) -const { assertPackagedWebClient } = require('./verify-packaged-web-client.cjs') +const { + SERVE_SELECTION_ENTRY, + assertPackagedServeSelectionEntry, + assertPackagedWebClient +} = require('./verify-packaged-serve-payload.cjs') const roots = [] function packagedResources({ index, assets = [], cli = true }) { - const resourcesDir = mkdtempSync(join(tmpdir(), 'verify-packaged-web-client-')) + const resourcesDir = mkdtempSync(join(tmpdir(), 'verify-packaged-serve-payload-')) roots.push(resourcesDir) const outDir = join(resourcesDir, 'app.asar.unpacked', 'out') if (cli) { @@ -70,3 +75,42 @@ describe('assertPackagedWebClient', () => { expect(() => assertPackagedWebClient(resourcesDir)).toThrow(/no unpacked CLI/) }) }) + +describe('assertPackagedServeSelectionEntry', () => { + function packagedEntry(files) { + const resourcesDir = mkdtempSync(join(tmpdir(), 'verify-packaged-serve-payload-')) + roots.push(resourcesDir) + const outDir = join(resourcesDir, 'app.asar.unpacked', 'out') + for (const [path, content] of Object.entries(files)) { + mkdirSync(dirname(join(outDir, path)), { recursive: true }) + writeFileSync(join(outDir, path), content) + } + return resourcesDir + } + + it('looks where orca serve runs the entry from', () => { + expect(SERVE_SELECTION_ENTRY).toBe(`main/${ORCAD_LOCAL_SERVE_SELECTION_ENTRY}.js`) + }) + + it('passes an unpacked entry whose relative requires are all unpacked', () => { + const resourcesDir = packagedEntry({ + [SERVE_SELECTION_ENTRY]: 'require("../chunks/a.js"); require("node:fs")', + 'main/chunks/a.js': 'require("./b"); require("../persistence")', + 'main/chunks/b.js': '', + 'main/persistence/index.js': '' + }) + expect(() => assertPackagedServeSelectionEntry(resourcesDir)).not.toThrow() + }) + + it('fails a package that left the entry inside app.asar (#27404)', () => { + expect(() => assertPackagedServeSelectionEntry(packagedEntry({}))).toThrow(/missing unpacked/) + }) + + it('fails when a chunk the entry reaches was not unpacked', () => { + const resourcesDir = packagedEntry({ + [SERVE_SELECTION_ENTRY]: 'require("../chunks/a.js")', + 'main/chunks/a.js': 'require("./b.js")' + }) + expect(() => assertPackagedServeSelectionEntry(resourcesDir)).toThrow(/chunks.b\.js/) + }) +}) diff --git a/config/scripts/verify-packaged-web-client.cjs b/config/scripts/verify-packaged-web-client.cjs deleted file mode 100644 index bbe4e999ae7..00000000000 --- a/config/scripts/verify-packaged-web-client.cjs +++ /dev/null @@ -1,48 +0,0 @@ -const { existsSync, readFileSync } = require('node:fs') -const { join, posix } = require('node:path') - -const WEB_CLIENT_INDEX = 'web-index.html' -const REMEDY = 'Run pnpm build:web (build:desktop and build:release already do).' - -function failure(message) { - return new Error(`[verify-packaged-web-client] ${message}`) -} - -/** The relative files web-index.html loads; the page is useless if any is missing. */ -function referencedAssets(html) { - const assets = new Set() - for (const match of html.matchAll(/\b(?:src|href)=["']\.\/([^"'?#]+)/g)) { - assets.add(posix.normalize(match[1])) - } - return [...assets] -} - -/** - * Why: orcad serves the browser client by path from the CLI's own app root, and it runs on plain - * Node, which cannot read app.asar. So the page and everything it loads must be unpacked beside - * out/cli, or `orca serve` comes up with no web client (#30100). - */ -function assertPackagedWebClient(resourcesDir) { - const outDir = join(resourcesDir, 'app.asar.unpacked', 'out') - if (!existsSync(join(outDir, 'cli'))) { - throw failure(`no unpacked CLI at ${join(outDir, 'cli')}; the web client root resolves from it`) - } - const webRoot = join(outDir, 'web') - const indexPath = join(webRoot, WEB_CLIENT_INDEX) - if (!existsSync(indexPath)) { - throw failure(`missing unpacked ${indexPath}. ${REMEDY}`) - } - const assets = referencedAssets(readFileSync(indexPath, 'utf8')) - if (assets.length === 0) { - throw failure(`${indexPath} references no ./ assets. ${REMEDY}`) - } - const missing = assets.filter( - (asset) => asset.startsWith('..') || !existsSync(join(webRoot, ...asset.split('/'))) - ) - if (missing.length > 0) { - throw failure(`${indexPath} loads files missing from ${webRoot}: ${missing.join(', ')}`) - } - console.log(`[verify-packaged-web-client] OK — ${String(assets.length)} asset(s) unpacked`) -} - -module.exports = { assertPackagedWebClient }