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
83 changes: 41 additions & 42 deletions src/cli-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@

import { dirname, isAbsolute, join, resolve } from 'path';
import { createReadStream, existsSync, statSync } from 'fs';
import { mkdir, mkdtemp, writeFile, copyFile, rm, readFile } from 'fs/promises';
import {
mkdir,
mkdtemp,
writeFile,
copyFile,
rm,
readFile,
readdir,
} from 'fs/promises';
import {
createTarPacker,
storeReaderToFile,
Expand Down Expand Up @@ -36,6 +44,36 @@ const readPackageJsonFile = async (packageJsonPath: string): Promise<any> => {
return JSON5.parse(content);
};

const readGeneratedTarballPaths = async (
packDestDir: string
): Promise<string[]> => {
const entries = await readdir(packDestDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.tgz'))
.map((entry) => join(packDestDir, entry.name))
.sort();
};

const resolveGeneratedTarballPath = async (
packDestDir: string
): Promise<string> => {
const tarballPaths = await readGeneratedTarballPaths(packDestDir);

if (tarballPaths.length === 1) {
return tarballPaths[0];
}

if (tarballPaths.length === 0) {
throw new Error(
`package pack did not produce a .tgz file in ${packDestDir}`
);
}

throw new Error(
`package pack produced multiple .tgz files in ${packDestDir}: ${tarballPaths.join(', ')}`
);
};

export type PackageManagerName = 'npm' | 'pnpm';

const dependencySectionKeys = new Set([
Expand Down Expand Up @@ -347,49 +385,14 @@ export const resolveWorkspaceFilesMerge = async (
};
};

const readPackResultPath = (
packageManager: PackageManagerName,
packDestDir: string,
stdout: string
): string => {
if (packageManager === 'pnpm') {
const parsed = JSON5.parse(stdout);
const packResult = Array.isArray(parsed) ? parsed[0] : parsed;
if (!packResult?.filename || typeof packResult.filename !== 'string') {
throw new Error('pnpm pack did not output a valid filename');
}

return isAbsolute(packResult.filename)
? packResult.filename
: join(packDestDir, packResult.filename);
}

const lines = stdout.trim().split('\n');
const filename =
lines.find((line) => line.trim().endsWith('.tgz')) ||
lines[lines.length - 1];
if (!filename || !filename.trim().endsWith('.tgz')) {
throw new Error('npm pack did not output a valid .tgz filename');
}

return join(packDestDir, filename.trim());
};

/**
* Execute package manager pack and return the generated tarball path
* @param packageManager - Package manager to use
* @param targetDir - Target directory to pack
* @param packDestDir - Directory to store the generated tarball (must exist)
* @returns Path to generated tarball
*/
const runPack = async (
packageManager: PackageManagerName,
targetDir: string,
packDestDir: string
): Promise<string> => {
const packArgs =
packageManager === 'pnpm'
? ['pack', '--json', '--pack-destination', packDestDir]
? ['pack', '--pack-destination', packDestDir]
: ['pack', '--pack-destination', packDestDir];

return new Promise((res, rej) => {
Expand All @@ -411,11 +414,7 @@ const runPack = async (

packProcess.on('close', (code) => {
if (code === 0) {
try {
res(readPackResultPath(packageManager, packDestDir, stdout.trim()));
} catch (error: any) {
rej(error);
}
resolveGeneratedTarballPath(packDestDir).then(res).catch(rej);
} else {
const errorMessage = `${packageManager} pack failed with exit code ${code}`;
const fullError = stderr
Expand Down
240 changes: 227 additions & 13 deletions tests/cli-nullability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,47 @@ import { EventEmitter } from 'events';
import { tmpdir } from 'os';
import { join, resolve } from 'path';

const { getFetchGitMetadataMock, resolveRawPackageJsonObjectMock, spawnMock } =
vi.hoisted(() => ({
getFetchGitMetadataMock: vi.fn(),
resolveRawPackageJsonObjectMock: vi.fn(),
spawnMock: vi.fn(),
}));
const {
createReadStreamMock,
createTarExtractorMock,
createEntryItemGeneratorMock,
createTarPackerMock,
extractToMock,
getFetchGitMetadataMock,
resolveRawPackageJsonObjectMock,
spawnMock,
storeReaderToFileMock,
} = vi.hoisted(() => ({
createReadStreamMock: vi.fn(),
createTarExtractorMock: vi.fn(),
createEntryItemGeneratorMock: vi.fn(),
createTarPackerMock: vi.fn(),
extractToMock: vi.fn(),
getFetchGitMetadataMock: vi.fn(),
resolveRawPackageJsonObjectMock: vi.fn(),
spawnMock: vi.fn(),
storeReaderToFileMock: vi.fn(),
}));

vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
createReadStream: createReadStreamMock,
};
});

vi.mock('tar-vern', async () => {
const actual = await vi.importActual<typeof import('tar-vern')>('tar-vern');
return {
...actual,
createTarExtractor: createTarExtractorMock,
createEntryItemGenerator: createEntryItemGeneratorMock,
createTarPacker: createTarPackerMock,
extractTo: extractToMock,
storeReaderToFile: storeReaderToFileMock,
};
});

vi.mock('../src/analyzer.ts', async () => {
const actual =
Expand Down Expand Up @@ -51,6 +86,65 @@ import { packAssets } from '../src/cli-internal.ts';
import { cliMain } from '../src/cli.ts';
import { createConsoleLogger } from '../src/internal';

const createMockSpawnProcess = (
stdoutChunks: string[] = [],
stderrChunks: string[] = [],
exitCode: number | null = 0,
signal: NodeJS.Signals | null = null
) => {
const childProcess = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
};
childProcess.stdout = new EventEmitter();
childProcess.stderr = new EventEmitter();

setTimeout(() => {
for (const chunk of stdoutChunks) {
childProcess.stdout.emit('data', chunk);
}
for (const chunk of stderrChunks) {
childProcess.stderr.emit('data', chunk);
}
childProcess.emit('close', exitCode, signal);
}, 0);

return childProcess;
};

const setupPackAssetsTarget = (targetDir: string, outputDir: string) => {
mkdirSync(targetDir, { recursive: true });
mkdirSync(outputDir, { recursive: true });
writeFileSync(
join(targetDir, 'package.json'),
JSON.stringify({ name: 'test-package', version: '1.0.0' }, null, 2)
);

resolveRawPackageJsonObjectMock.mockResolvedValue({
metadata: {
name: 'test-package',
version: '1.0.0',
},
sourceMap: new Map<string, string>(),
});
};

const mockPackSpawn = (
tarballNames: string[],
stdoutChunks: string[] = [],
stderrChunks: string[] = [],
exitCode: number | null = 0,
signal: NodeJS.Signals | null = null
) => {
spawnMock.mockImplementation((_command, args: string[]) => {
const packDestDir = args[args.length - 1];
for (const tarballName of tarballNames) {
writeFileSync(join(packDestDir, tarballName), 'dummy tarball');
}
return createMockSpawnProcess(stdoutChunks, stderrChunks, exitCode, signal);
});
};

describe('CLI nullability regressions', () => {
let tempDir: string;

Expand All @@ -66,8 +160,20 @@ describe('CLI nullability regressions', () => {
getFetchGitMetadataMock.mockReset();
resolveRawPackageJsonObjectMock.mockReset();
spawnMock.mockReset();
createReadStreamMock.mockReset();
createTarExtractorMock.mockReset();
createEntryItemGeneratorMock.mockReset();
createTarPackerMock.mockReset();
extractToMock.mockReset();
storeReaderToFileMock.mockReset();

getFetchGitMetadataMock.mockReturnValue(async () => ({}));
createReadStreamMock.mockReturnValue(new EventEmitter() as any);
createTarExtractorMock.mockReturnValue({} as any);
createEntryItemGeneratorMock.mockReturnValue((async function* () {})());
createTarPackerMock.mockReturnValue({} as any);
extractToMock.mockResolvedValue(undefined);
storeReaderToFileMock.mockResolvedValue(undefined);
});

it('should fail when package.json readme source directory is unknown', async () => {
Expand Down Expand Up @@ -102,17 +208,125 @@ describe('CLI nullability regressions', () => {
);
});

it('should use the sole generated tarball instead of stdout filename logs', async () => {
const targetDir = join(tempDir, 'source');
const outputDir = join(tempDir, 'output');
setupPackAssetsTarget(targetDir, outputDir);

mockPackSpawn(
['test-package-1.0.0.tgz'],
[
'> test-package@1.0.0 prepack C:\\temp\\source\n',
'{"filename":"fake-from-script.tgz"}\n',
'{\n',
' "name": "test-package",\n',
' "version": "1.0.0",\n',
' "filename": "test-package-1.0.0.tgz"\n',
'}\n',
]
);

const result = await packAssets(
targetDir,
outputDir,
true,
true,
new Set(['version']),
undefined,
true,
'^',
createConsoleLogger(),
true,
'pnpm'
);

expect(result).toMatchObject({
packageFileName: 'test-package-1.0.0.tgz',
metadata: {
name: 'test-package',
version: '1.0.0',
},
});

expect(spawnMock).toHaveBeenCalledTimes(1);
const [command, args, options] = spawnMock.mock.calls[0];
expect(command).toBe('pnpm');
expect(args).toEqual(['pack', '--pack-destination', expect.any(String)]);
const packDestDir = args[args.length - 1];
expect(options).toMatchObject({
cwd: targetDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
expect(createReadStreamMock).toHaveBeenCalledWith(
join(packDestDir, 'test-package-1.0.0.tgz')
);
expect(storeReaderToFileMock).toHaveBeenCalledTimes(1);
});

it('should fail when pack destination does not contain a tarball', async () => {
const targetDir = join(tempDir, 'source');
const outputDir = join(tempDir, 'output');
setupPackAssetsTarget(targetDir, outputDir);

mockPackSpawn([], ['{"filename":"missing-from-stdout.tgz"}\n']);

await expect(
packAssets(
targetDir,
outputDir,
true,
true,
new Set(['version']),
undefined,
true,
'^',
createConsoleLogger(),
true,
'pnpm'
)
).rejects.toThrow(/did not produce a \.tgz file/);

expect(createReadStreamMock).not.toHaveBeenCalled();
expect(storeReaderToFileMock).not.toHaveBeenCalled();
});

it('should fail when pack destination contains multiple tarballs', async () => {
const targetDir = join(tempDir, 'source');
const outputDir = join(tempDir, 'output');
setupPackAssetsTarget(targetDir, outputDir);

mockPackSpawn(
['first-package.tgz', 'second-package.tgz'],
['{"filename":"test-package-1.0.0.tgz"}\n']
);

await expect(
packAssets(
targetDir,
outputDir,
true,
true,
new Set(['version']),
undefined,
true,
'^',
createConsoleLogger(),
true,
'pnpm'
)
).rejects.toThrow(/produced multiple \.tgz files/);

expect(createReadStreamMock).not.toHaveBeenCalled();
expect(storeReaderToFileMock).not.toHaveBeenCalled();
});

it('should treat npm publish termination without an exit code as failure', async () => {
const tarballPath = join(tempDir, 'package.tgz');
writeFileSync(tarballPath, 'dummy tarball');

spawnMock.mockImplementation(() => {
const publishProcess = new EventEmitter();
setTimeout(() => {
publishProcess.emit('close', null, 'SIGTERM');
}, 0);
return publishProcess;
});
spawnMock.mockImplementation(() =>
createMockSpawnProcess([], [], null, 'SIGTERM')
);

const errors: string[] = [];
const logger = {
Expand Down