Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 97 additions & 13 deletions packages/worker/src/__unit__/runtimeEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const builderConfig: Ios.BuilderEnvironment | Android.BuilderEnvironment = {};
describe('prepareRuntimeEnvironment', () => {
beforeEach(() => {
jest.mocked(spawn).mockReset();
jest.mocked(pathExists).mockReset();
jest.mocked(ctx.logger.info).mockReset();
jest.mocked(ctx.logger.warn).mockReset();
jest.mocked(ctx.logger.error).mockReset();
ctx.env = { ...process.env };
builderConfig.node = undefined;
});

describe('installNode', () => {
Expand All @@ -48,7 +54,6 @@ describe('prepareRuntimeEnvironment', () => {
}
return Promise.resolve(spawnResult);
});
jest.mocked(spawn).mockResolvedValue(spawnResult);
});

it('should install the specified version of Node.js', async () => {
Expand Down Expand Up @@ -159,30 +164,109 @@ describe('prepareRuntimeEnvironment', () => {
});
});

it('should throw an error if installation fails', async () => {
it('logs nvm ls-remote output after install failure', async () => {
const version = 'invalid-version';
builderConfig.node = version;

jest.mocked(spawn).mockImplementation((cmd, _args, _opts) => {
if (cmd === 'bash') {
jest.mocked(spawn).mockImplementation((cmd, args, _opts) => {
if (cmd === 'bash' && args?.[1]?.includes('nvm install')) {
return Promise.reject(new Error('nvm install failed'));
}
if (cmd === 'bash' && args?.[1]?.includes('nvm ls-remote')) {
return Promise.resolve({
...spawnResult,
stdout: 'v20.18.0\nv20.18.1\n',
});
}
return Promise.resolve(spawnResult);
});
jest.mocked(pathExists).mockResolvedValue(true);

await expect(prepareRuntimeEnvironment(ctx, builderConfig, false)).rejects.toThrow(
'Failed to install Node.js'
);
expect(spawn).toHaveBeenCalledWith(
'bash',
['-c', 'source ~/.nvm/nvm.sh && nvm ls-remote'],
expect.objectContaining({ stdio: 'pipe', env: ctx.env })
);
expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining('nvm ls-remote output'));
});

it('retries node install without NVM_NODEJS_ORG_MIRROR', async () => {
const version = '20.18.1';
builderConfig.node = version;
ctx.env.NVM_NODEJS_ORG_MIRROR = 'https://example-mirror.invalid';

let installAttempt = 0;
jest.mocked(spawn).mockImplementation((cmd, args, _opts) => {
if (cmd === 'bash' && args?.[1]?.includes('nvm install')) {
installAttempt += 1;
if (installAttempt === 1) {
return Promise.reject(new Error('first install failed'));
}
return Promise.resolve({
...spawnResult,
stdout:
'Downloading and installing node v20.18.1...\nNow using node v20.18.1 (npm v10.8.2)\n',
});
}
if (cmd === 'bash' && args?.[1]?.includes('nvm ls-remote')) {
return Promise.resolve({
...spawnResult,
output: [
'',
"Version 'invalid-version' not found - try `nvm ls-remote` to browse available versions.\n",
],
stdout: '',
stderr:
"Version 'invalid-version' not found - try `nvm ls-remote` to browse available versions.\n",
status: 3,
stdout: 'v20.18.0\nv20.18.1\n',
});
}
return Promise.resolve(spawnResult);
});
jest.mocked(pathExists).mockResolvedValue(false);
jest.mocked(pathExists).mockResolvedValue(true);

await expect(prepareRuntimeEnvironment(ctx, builderConfig, false)).resolves.toBeUndefined();

const installCalls = jest
.mocked(spawn)
.mock.calls.filter(([cmd, args]) => cmd === 'bash' && args?.[1]?.includes('nvm install'));
expect(installCalls).toHaveLength(2);

const firstAttemptEnv = installCalls[0][2].env;
const secondAttemptEnv = installCalls[1][2].env;
expect(firstAttemptEnv.NVM_NODEJS_ORG_MIRROR).toBe('https://example-mirror.invalid');
expect(secondAttemptEnv.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
});

it('throws when retry also fails', async () => {
const version = 'invalid-version';
builderConfig.node = version;
ctx.env.NVM_NODEJS_ORG_MIRROR = 'https://example-mirror.invalid';

let installAttempt = 0;
jest.mocked(spawn).mockImplementation((cmd, args, _opts) => {
if (cmd === 'bash' && args?.[1]?.includes('nvm install')) {
installAttempt += 1;
return Promise.reject(new Error(`nvm install failed on attempt ${installAttempt}`));
}
if (cmd === 'bash' && args?.[1]?.includes('nvm ls-remote')) {
return Promise.resolve({
...spawnResult,
stdout: 'v20.18.0\n',
});
}
return Promise.resolve(spawnResult);
});
jest.mocked(pathExists).mockResolvedValue(true);

await expect(prepareRuntimeEnvironment(ctx, builderConfig, false)).rejects.toThrow(
'Failed to install Node.js'
);

const installCalls = jest
.mocked(spawn)
.mock.calls.filter(([cmd, args]) => cmd === 'bash' && args?.[1]?.includes('nvm install'));
const lsRemoteCalls = jest
.mocked(spawn)
.mock.calls.filter(([cmd, args]) => cmd === 'bash' && args?.[1]?.includes('nvm ls-remote'));
expect(installCalls).toHaveLength(2);
expect(lsRemoteCalls).toHaveLength(2);
});
});
});
80 changes: 62 additions & 18 deletions packages/worker/src/runtimeEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,31 +135,75 @@ async function installNode(ctx: PreDownloadBuildContext, version: string): Promi
let sanitizedVersion = version.startsWith('v') ? version.slice(1) : version;
try {
ctx.logger.info(`Installing node v${sanitizedVersion}`);
sanitizedVersion = await installNodeWithNvm(ctx, version, sanitizedVersion, ctx.env);
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install Node.js v${version}\n`);
await logNvmLsRemoteOutput(ctx, ctx.env);
if (!ctx.env.NVM_NODEJS_ORG_MIRROR) {
throw new SystemDepsInstallError('Node.js');
}

const retryEnv = { ...ctx.env };
delete retryEnv.NVM_NODEJS_ORG_MIRROR;
ctx.logger.warn(
`Retrying Node.js install for v${version} after unsetting NVM_NODEJS_ORG_MIRROR`
);
try {
sanitizedVersion = await installNodeWithNvm(ctx, version, sanitizedVersion, retryEnv);
} catch (retryErr: any) {
ctx.logger.error(
{ err: retryErr },
`Failed to install Node.js v${version} after retrying without NVM_NODEJS_ORG_MIRROR\n`
);
await logNvmLsRemoteOutput(ctx, retryEnv);
throw new SystemDepsInstallError('Node.js');
}
}
return sanitizedVersion;
}

async function installNodeWithNvm(
ctx: PreDownloadBuildContext,
version: string,
sanitizedVersion: string,
env: PreDownloadBuildContext['env']
): Promise<string> {
const { stdout } = await spawn('bash', ['-c', `source ~/.nvm/nvm.sh && nvm install ${version}`], {
logger: ctx.logger,
env,
});

const installedNodeVersion = stdout.match(/Now using node v(\d+\.\d+\.\d+)/)?.[1] ?? sanitizedVersion;
await spawn('bash', ['-c', `source ~/.nvm/nvm.sh && nvm alias default ${installedNodeVersion}`], {
logger: ctx.logger,
env,
});
const nodeDir = `${os.homedir()}/.nvm/versions/node/v${installedNodeVersion}`;
ctx.env.PATH = `${nodeDir}/bin:${ctx.env.PATH}`;
const nodeBinPath = `${nodeDir}/bin/node`;
if (!(await fs.pathExists(nodeBinPath))) {
throw new Error(`node executable was not found in ${nodeBinPath}`);
}
return installedNodeVersion;
}

async function logNvmLsRemoteOutput(
ctx: PreDownloadBuildContext,
env: PreDownloadBuildContext['env']
): Promise<void> {
try {
const { stdout } = await spawn(
'bash',
['-c', `source ~/.nvm/nvm.sh && nvm install ${version}`],
['-c', 'source ~/.nvm/nvm.sh && nvm ls-remote'],
{
logger: ctx.logger,
env: ctx.env,
stdio: 'pipe',
env,
}
);

sanitizedVersion = stdout.match(/Now using node v(\d+\.\d+\.\d+)/)?.[1] ?? sanitizedVersion;
await spawn('bash', ['-c', `source ~/.nvm/nvm.sh && nvm alias default ${sanitizedVersion}`], {
logger: ctx.logger,
env: ctx.env,
});
const nodeDir = `${os.homedir()}/.nvm/versions/node/v${sanitizedVersion}`;
ctx.env.PATH = `${nodeDir}/bin:${ctx.env.PATH}`;
const nodeBinPath = `${nodeDir}/bin/node`;
if (!(await fs.pathExists(nodeBinPath))) {
throw new Error(`node executable was not found in ${nodeBinPath}`);
}
ctx.logger.info(`nvm ls-remote output:\n${stdout}`);
} catch (err: any) {
ctx.logger.error({ err }, `Failed to install Node.js v${version}\n`);
throw new SystemDepsInstallError('Node.js');
ctx.logger.warn({ err }, 'Failed to run nvm ls-remote');
}
return sanitizedVersion;
}

async function installBundler(ctx: PreDownloadBuildContext, version: string): Promise<void> {
Expand Down
Loading