Files
setup-java/__tests__/util-install.test.ts
T
Bruno Borges 885218c5e4 Move the extracted JDK into the tool-cache and speed up extraction (#1206)
Two wall-clock optimizations on the JDK install path.

`tc.cacheDir` recursively copies the extracted tree into RUNNER_TOOL_CACHE,
so a 200-600MB JDK is written to disk twice. The extraction directory and
the tool-cache normally share a filesystem, so `cacheJdkDir` renames it
instead and writes the `.complete` marker itself, mirroring the destination
layout `tc.cacheDir` produces. It falls back to the copy when the tool-cache
location is unknown, when the source is not a real directory (a symlinked
source would otherwise leave a dangling entry once RUNNER_TEMP is cleaned),
or when the rename fails - a cross-device tool-cache, or anti-virus holding
a handle on Windows. The rename is atomic, so the source is still intact
for the fallback.

Extraction now uses `pigz` for tarballs when the runner provides it, and
Windows zips go through the bundled `tar.exe` rather than `tc.extractZip`,
which shells out to PowerShell's much slower `Expand-Archive`. Both fall
back to the stock extraction and clean up the abandoned directory first.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: b76d8cb0-f629-46e1-bf9a-ffde06948644
2026-08-04 23:05:06 -04:00

413 lines
13 KiB
TypeScript

import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
jest.unstable_mockModule('@actions/core', () => ({
debug: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
getInput: jest.fn(() => ''),
isDebug: jest.fn(() => false),
addPath: jest.fn(),
exportVariable: jest.fn(),
setOutput: jest.fn()
}));
jest.unstable_mockModule('@actions/tool-cache', () => ({
cacheDir: jest.fn(),
extractTar: jest.fn(),
extractZip: jest.fn(),
extract7z: jest.fn()
}));
jest.unstable_mockModule('@actions/exec', () => ({
exec: jest.fn()
}));
jest.unstable_mockModule('@actions/io', () => ({
which: jest.fn(),
rmRF: jest.fn(async (target: string) =>
fs.rmSync(target, {recursive: true, force: true})
),
mkdirP: jest.fn(async (target: string) =>
fs.mkdirSync(target, {recursive: true})
)
}));
jest.unstable_mockModule('@actions/http-client', () => ({
HttpClient: jest.fn(),
HttpClientError: class HttpClientError extends Error {}
}));
const tc = await import('@actions/tool-cache');
const exec = await import('@actions/exec');
const io = await import('@actions/io');
const {cacheJdkDir, extractJdkFile} = await import('../src/util.js');
const originalToolCache = process.env['RUNNER_TOOL_CACHE'];
const originalTemp = process.env['RUNNER_TEMP'];
const originalPlatform = process.platform;
let workDir: string;
function setPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true
});
}
beforeEach(() => {
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-java-util-'));
process.env['RUNNER_TOOL_CACHE'] = path.join(workDir, 'toolcache');
process.env['RUNNER_TEMP'] = path.join(workDir, 'temp');
fs.mkdirSync(process.env['RUNNER_TEMP'], {recursive: true});
});
afterEach(() => {
jest.clearAllMocks();
setPlatform(originalPlatform);
while (lockedDirs.length) {
fs.chmodSync(lockedDirs.pop()!, 0o755);
}
fs.rmSync(workDir, {recursive: true, force: true});
if (originalToolCache === undefined) {
delete process.env['RUNNER_TOOL_CACHE'];
} else {
process.env['RUNNER_TOOL_CACHE'] = originalToolCache;
}
if (originalTemp === undefined) {
delete process.env['RUNNER_TEMP'];
} else {
process.env['RUNNER_TEMP'] = originalTemp;
}
});
function createJdkDir(name = 'jdk-source'): string {
const sourceDir = path.join(workDir, name);
fs.mkdirSync(path.join(sourceDir, 'bin'), {recursive: true});
fs.writeFileSync(path.join(sourceDir, 'bin', 'java'), 'binary');
fs.writeFileSync(path.join(sourceDir, 'release'), 'JAVA_VERSION="17"');
return sourceDir;
}
// A rename needs write permission on the source's parent directory, so making
// that parent read-only is a portable way to force the same failure a
// cross-device tool-cache (EXDEV) or a Windows anti-virus handle (EPERM) would.
// Root ignores the permission bits, so those tests are skipped there.
const canForceRenameFailure =
process.platform !== 'win32' &&
typeof process.getuid === 'function' &&
process.getuid() !== 0;
const itUnlessRoot = canForceRenameFailure ? it : it.skip;
const lockedDirs: string[] = [];
function createUnrenameableJdkDir(): string {
const parent = path.join(workDir, 'locked');
fs.mkdirSync(parent, {recursive: true});
const sourceDir = path.join(parent, 'jdk-source');
fs.mkdirSync(path.join(sourceDir, 'bin'), {recursive: true});
fs.writeFileSync(path.join(sourceDir, 'bin', 'java'), 'binary');
fs.chmodSync(parent, 0o555);
lockedDirs.push(parent);
return sourceDir;
}
describe('cacheJdkDir', () => {
it('moves the JDK into the tool-cache instead of copying it', async () => {
const sourceDir = createJdkDir();
const javaPath = await cacheJdkDir(
sourceDir,
'Java_temurin_jdk',
'17.0.1',
'x64'
);
expect(javaPath).toBe(
path.join(
process.env['RUNNER_TOOL_CACHE']!,
'Java_temurin_jdk',
'17.0.1',
'x64'
)
);
expect(fs.existsSync(path.join(javaPath, 'bin', 'java'))).toBe(true);
expect(fs.existsSync(path.join(javaPath, 'release'))).toBe(true);
// the source is moved, not copied, so it no longer exists
expect(fs.existsSync(sourceDir)).toBe(false);
expect(tc.cacheDir).not.toHaveBeenCalled();
});
it('writes the .complete marker expected by the tool-cache', async () => {
const javaPath = await cacheJdkDir(
createJdkDir(),
'Java_temurin_jdk',
'17.0.1',
'x64'
);
expect(fs.existsSync(`${javaPath}.complete`)).toBe(true);
});
it('replaces an existing tool-cache entry', async () => {
const destPath = path.join(
process.env['RUNNER_TOOL_CACHE']!,
'Java_temurin_jdk',
'17.0.1',
'x64'
);
fs.mkdirSync(destPath, {recursive: true});
fs.writeFileSync(path.join(destPath, 'stale'), 'stale');
const javaPath = await cacheJdkDir(
createJdkDir(),
'Java_temurin_jdk',
'17.0.1',
'x64'
);
expect(fs.existsSync(path.join(javaPath, 'stale'))).toBe(false);
expect(fs.existsSync(path.join(javaPath, 'bin', 'java'))).toBe(true);
});
it('normalizes the version the same way as tc.cacheDir', async () => {
const javaPath = await cacheJdkDir(
createJdkDir(),
'Java_temurin_jdk',
'v17.0.1',
'x64'
);
expect(path.basename(path.dirname(javaPath))).toBe('17.0.1');
});
it('keeps unparseable versions as-is', async () => {
const javaPath = await cacheJdkDir(
createJdkDir(),
'Java_temurin_jdk',
'17.0.1-ea.3',
'x64'
);
expect(path.basename(path.dirname(javaPath))).toBe('17.0.1-ea.3');
});
it('falls back to tc.cacheDir when the move fails', async () => {
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
const missingDir = path.join(workDir, 'does-not-exist');
const javaPath = await cacheJdkDir(
missingDir,
'Java_temurin_jdk',
'17.0.1',
'x64'
);
expect(javaPath).toBe('/fallback/path');
expect(tc.cacheDir).toHaveBeenCalledWith(
missingDir,
'Java_temurin_jdk',
'17.0.1',
'x64'
);
});
itUnlessRoot(
'falls back to tc.cacheDir when the rename itself fails',
async () => {
const sourceDir = createUnrenameableJdkDir();
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
await expect(
cacheJdkDir(sourceDir, 'Java_temurin_jdk', '17.0.1', 'x64')
).resolves.toBe('/fallback/path');
// the source must survive so the copy-based fallback can still read it
expect(fs.existsSync(path.join(sourceDir, 'bin', 'java'))).toBe(true);
}
);
itUnlessRoot(
'does not leave a .complete marker behind when the rename fails',
async () => {
const destPath = path.join(
process.env['RUNNER_TOOL_CACHE']!,
'Java_temurin_jdk',
'17.0.1',
'x64'
);
fs.mkdirSync(destPath, {recursive: true});
fs.writeFileSync(`${destPath}.complete`, '');
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
await cacheJdkDir(
createUnrenameableJdkDir(),
'Java_temurin_jdk',
'17.0.1',
'x64'
);
// a stale marker without a matching installation would make the
// tool-cache resolve a directory that is no longer there
expect(fs.existsSync(`${destPath}.complete`)).toBe(false);
}
);
it('falls back to tc.cacheDir for symlinked sources', async () => {
const realDir = createJdkDir('real-jdk');
const linkDir = path.join(workDir, 'linked-jdk');
fs.symlinkSync(realDir, linkDir, 'dir');
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
await expect(
cacheJdkDir(linkDir, 'Java_temurin_jdk', '17.0.1', 'x64')
).resolves.toBe('/fallback/path');
// moving the symlink itself would leave a dangling tool-cache entry
expect(fs.lstatSync(linkDir).isSymbolicLink()).toBe(true);
});
it('defaults the architecture the same way as tc.cacheDir', async () => {
const javaPath = await cacheJdkDir(
createJdkDir(),
'Java_temurin_jdk',
'17.0.1',
''
);
expect(javaPath).toBe(
path.join(
process.env['RUNNER_TOOL_CACHE']!,
'Java_temurin_jdk',
'17.0.1',
os.arch()
)
);
});
it('falls back to tc.cacheDir when the tool-cache location is unknown', async () => {
delete process.env['RUNNER_TOOL_CACHE'];
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
await expect(
cacheJdkDir(createJdkDir(), 'Java_temurin_jdk', '17.0.1', 'x64')
).resolves.toBe('/fallback/path');
});
});
describe('extractJdkFile', () => {
it('uses pigz for tarballs when it is available', async () => {
(io.which as jest.Mock).mockResolvedValue('/usr/bin/pigz' as never);
(tc.extractTar as jest.Mock).mockResolvedValue('/extracted' as never);
await expect(extractJdkFile('/tmp/jdk.tar.gz')).resolves.toBe('/extracted');
expect(tc.extractTar).toHaveBeenCalledWith(
'/tmp/jdk.tar.gz',
expect.stringContaining(process.env['RUNNER_TEMP']!),
['--use-compress-program', '/usr/bin/pigz -d', '-x']
);
});
it('falls back to gzip when pigz is not installed', async () => {
(io.which as jest.Mock).mockResolvedValue('' as never);
(tc.extractTar as jest.Mock).mockResolvedValue('/extracted' as never);
await expect(extractJdkFile('/tmp/jdk.tar.gz')).resolves.toBe('/extracted');
expect(tc.extractTar).toHaveBeenCalledWith('/tmp/jdk.tar.gz');
});
it('falls back to gzip when pigz extraction fails', async () => {
(io.which as jest.Mock).mockResolvedValue('/usr/bin/pigz' as never);
(tc.extractTar as jest.Mock)
.mockRejectedValueOnce(new Error('pigz exploded') as never)
.mockResolvedValue('/extracted' as never);
await expect(extractJdkFile('/tmp/jdk.tar.gz')).resolves.toBe('/extracted');
expect(tc.extractTar).toHaveBeenNthCalledWith(2, '/tmp/jdk.tar.gz');
});
it('cleans up the abandoned folder when pigz extraction fails', async () => {
(io.which as jest.Mock).mockResolvedValue('/usr/bin/pigz' as never);
let pigzDest: string | undefined;
(tc.extractTar as jest.Mock)
.mockImplementationOnce((...args: unknown[]) => {
pigzDest = args[1] as string;
throw new Error('pigz exploded');
})
.mockResolvedValue('/extracted' as never);
await extractJdkFile('/tmp/jdk.tar.gz');
expect(pigzDest).toBeDefined();
expect(fs.existsSync(pigzDest!)).toBe(false);
});
it('ignores pigz when its path contains whitespace', async () => {
(io.which as jest.Mock).mockResolvedValue(
'C:\\Program Files\\pigz.exe' as never
);
(tc.extractTar as jest.Mock).mockResolvedValue('/extracted' as never);
await extractJdkFile('/tmp/jdk.tar.gz');
// tar word-splits --use-compress-program, so a spaced path is unusable
expect(tc.extractTar).toHaveBeenCalledWith('/tmp/jdk.tar.gz');
});
it('leaves uncompressed tarballs on the default extraction path', async () => {
(tc.extractTar as jest.Mock).mockResolvedValue('/extracted' as never);
await expect(extractJdkFile('/tmp/jdk.tar')).resolves.toBe('/extracted');
expect(tc.extractTar).toHaveBeenCalledWith('/tmp/jdk.tar');
expect(io.which).not.toHaveBeenCalled();
});
it('uses the bundled tar.exe for zip archives on Windows', async () => {
setPlatform('win32');
const systemRoot = path.join(workDir, 'Windows');
fs.mkdirSync(path.join(systemRoot, 'System32'), {recursive: true});
const systemTar = path.join(systemRoot, 'System32', 'tar.exe');
fs.writeFileSync(systemTar, '');
process.env['SystemRoot'] = systemRoot;
const javaPath = await extractJdkFile('/tmp/jdk.zip');
expect(tc.extractZip).not.toHaveBeenCalled();
expect(exec.exec).toHaveBeenCalledWith(
`"${systemTar}"`,
['-xf', '/tmp/jdk.zip', '-C', javaPath],
{silent: true}
);
expect(fs.existsSync(javaPath)).toBe(true);
});
it('falls back to tc.extractZip when tar.exe fails', async () => {
setPlatform('win32');
const systemRoot = path.join(workDir, 'Windows');
fs.mkdirSync(path.join(systemRoot, 'System32'), {recursive: true});
fs.writeFileSync(path.join(systemRoot, 'System32', 'tar.exe'), '');
process.env['SystemRoot'] = systemRoot;
let tarDest: string | undefined;
(exec.exec as jest.Mock).mockImplementation((...args: unknown[]) => {
tarDest = (args[1] as string[])[3];
throw new Error('boom');
});
(tc.extractZip as jest.Mock).mockResolvedValue('/extracted' as never);
await expect(extractJdkFile('/tmp/jdk.zip')).resolves.toBe('/extracted');
expect(tarDest).toBeDefined();
expect(fs.existsSync(tarDest!)).toBe(false);
});
it('uses tc.extractZip on non-Windows platforms', async () => {
setPlatform('linux');
(tc.extractZip as jest.Mock).mockResolvedValue('/extracted' as never);
await expect(extractJdkFile('/tmp/jdk.zip')).resolves.toBe('/extracted');
expect(exec.exec).not.toHaveBeenCalled();
});
});