Skip to content

Commit 5dcb7d1

Browse files
authored
Add option packToPath to pack packages instead of publishing (#1076)
1 parent ea8b1aa commit 5dcb7d1

14 files changed

Lines changed: 486 additions & 19 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "Add option packToPath to pack packages instead of publishing",
4+
"packageName": "beachball",
5+
"email": "elcraig@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

src/__e2e__/publishGit.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, beforeAll, beforeEach, afterEach, jest } from '@jest/globals';
1+
import { describe, expect, it, beforeEach, afterEach } from '@jest/globals';
22
import fs from 'fs-extra';
33
import { defaultRemoteBranchName } from '../__fixtures__/gitDefaults';
44
import { generateChangeFiles, getChangeFiles } from '../__fixtures__/changeFiles';
@@ -38,10 +38,6 @@ describe('publish command (git)', () => {
3838
};
3939
}
4040

41-
beforeAll(() => {
42-
jest.setTimeout(30000);
43-
});
44-
4541
beforeEach(() => {
4642
repositoryFactory = new RepositoryFactory('single');
4743
});

src/__e2e__/publishRegistry.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, afterEach, jest } from '@jest/globals';
2+
import fs from 'fs';
23
import { defaultRemoteBranchName } from '../__fixtures__/gitDefaults';
34
import { generateChangeFiles } from '../__fixtures__/changeFiles';
45
import { initMockLogs } from '../__fixtures__/mockLogs';
@@ -9,6 +10,7 @@ import { publish } from '../commands/publish';
910
import { getDefaultOptions } from '../options/getDefaultOptions';
1011
import type { BeachballOptions } from '../types/BeachballOptions';
1112
import { initNpmMock } from '../__fixtures__/mockNpm';
13+
import { removeTempDir, tmpdir } from '../__fixtures__/tmpdir';
1214

1315
// Spawning actual npm to run commands against a fake registry is extremely slow, so mock it for
1416
// this test (packagePublish covers the more complete npm registry scenario).
@@ -22,11 +24,12 @@ describe('publish command (registry)', () => {
2224

2325
let repositoryFactory: RepositoryFactory | undefined;
2426
let repo: Repository | undefined;
27+
let packToPath: string | undefined;
2528

2629
// show error logs for these tests
2730
const logs = initMockLogs({ alsoLog: ['error'] });
2831

29-
function getOptions(): BeachballOptions {
32+
function getOptions(options?: Partial<BeachballOptions>): BeachballOptions {
3033
return {
3134
...getDefaultOptions(),
3235
branch: defaultRemoteBranchName,
@@ -41,13 +44,16 @@ describe('publish command (registry)', () => {
4144
tag: 'latest',
4245
yes: true,
4346
access: 'public',
47+
...options,
4448
};
4549
}
4650

4751
afterEach(() => {
4852
repositoryFactory?.cleanUp();
4953
repositoryFactory = undefined;
5054
repo = undefined;
55+
packToPath && removeTempDir(packToPath);
56+
packToPath = undefined;
5157
});
5258

5359
it('publishes single package', async () => {
@@ -66,6 +72,21 @@ describe('publish command (registry)', () => {
6672
expect(publishedPackage.versions).toHaveLength(1);
6773
});
6874

75+
it('packs single package', async () => {
76+
repositoryFactory = new RepositoryFactory('single');
77+
repo = repositoryFactory.cloneRepository();
78+
packToPath = tmpdir({ prefix: 'beachball-pack-' });
79+
80+
const options = getOptions({ packToPath });
81+
generateChangeFiles(['foo'], options);
82+
repo.push();
83+
84+
await publish(options);
85+
86+
expect(fs.readdirSync(packToPath)).toEqual(['1-foo-1.1.0.tgz']);
87+
await npmShow('foo', { shouldFail: true });
88+
});
89+
6990
it('publishes in monorepo with mixed public and private packages', async () => {
7091
repositoryFactory = new RepositoryFactory({
7192
folders: {
@@ -115,6 +136,34 @@ describe('publish command (registry)', () => {
115136
expect(showBar['dist-tags'].latest).toEqual('1.1.0');
116137
});
117138

139+
it('packs many packages', async () => {
140+
const packageNames = Array.from({ length: 11 }, (_, i) => `pkg-${i + 1}`);
141+
repositoryFactory = new RepositoryFactory({
142+
folders: {
143+
packages: Object.fromEntries(
144+
packageNames.map((name, i) => [
145+
name,
146+
// Each package depends on the next one, so they must be published in reverse alphabetical order
147+
{ version: '1.0.0', dependencies: { [packageNames[i + 1] || 'other']: '^1.0.0' } },
148+
])
149+
),
150+
},
151+
});
152+
repo = repositoryFactory.cloneRepository();
153+
packToPath = tmpdir({ prefix: 'beachball-pack-' });
154+
155+
const options = getOptions({ packToPath, groupChanges: true });
156+
generateChangeFiles(packageNames, options);
157+
repo.push();
158+
159+
await publish(options);
160+
161+
expect(fs.readdirSync(packToPath).sort()).toEqual(
162+
[...packageNames].reverse().map((name, i) => `${String(i + 1).padStart(2, '0')}-${name}-1.1.0.tgz`)
163+
);
164+
await npmShow('pkg-1', { shouldFail: true });
165+
});
166+
118167
it('succeeds even with a non-existent package listed in a change file', async () => {
119168
repositoryFactory = new RepositoryFactory({
120169
folders: {

src/__fixtures__/mockNpm.test.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from '@jes
66
import fs from 'fs-extra';
77
import { type NpmResult, npm } from '../packageManager/npm';
88
import type { PackageJson } from '../types/PackageInfo';
9-
import { initNpmMock, _makeRegistryData, _mockNpmPublish, _mockNpmShow, type MockNpmResult } from './mockNpm';
9+
import {
10+
initNpmMock,
11+
_makeRegistryData,
12+
_mockNpmPack,
13+
_mockNpmPublish,
14+
_mockNpmShow,
15+
type MockNpmResult,
16+
} from './mockNpm';
1017

1118
jest.mock('fs-extra');
1219
jest.mock('../packageManager/npm');
@@ -277,6 +284,69 @@ describe('_mockNpmPublish', () => {
277284
});
278285
});
279286

287+
describe('_mockNpmPack', () => {
288+
let packageJson: PackageJson | undefined;
289+
let writtenFiles: (fs.PathLike | number)[] = [];
290+
291+
beforeAll(() => {
292+
(fs.readJsonSync as jest.MockedFunction<typeof fs.readJsonSync>).mockImplementation(() => {
293+
if (!packageJson) throw new Error('packageJson not set');
294+
return packageJson;
295+
});
296+
(fs.writeFileSync as jest.MockedFunction<typeof fs.writeFileSync>).mockImplementation(filePath => {
297+
writtenFiles.push(String(filePath).replace(/\\/g, '/'));
298+
});
299+
});
300+
301+
afterEach(() => {
302+
packageJson = undefined;
303+
writtenFiles = [];
304+
});
305+
306+
afterAll(() => {
307+
jest.restoreAllMocks();
308+
});
309+
310+
it('throws if cwd is not specified', async () => {
311+
await expect(() => _mockNpmPack({}, [], { cwd: undefined })).rejects.toThrow('cwd is required for mock npm pack');
312+
});
313+
314+
it('errors if reading package.json fails', async () => {
315+
// this error is from the fs.readJsonSync mock, but it's the same code path as if reading the file fails
316+
await expect(() => _mockNpmPack({}, [], { cwd: 'fake' })).rejects.toThrow('packageJson not set');
317+
});
318+
319+
it('packs unscoped package', async () => {
320+
const registryData = {};
321+
packageJson = { name: 'foo', version: '1.0.0' };
322+
const result = await _mockNpmPack(registryData, [], { cwd: 'fake' });
323+
expect(result).toEqual({
324+
success: true,
325+
failed: false,
326+
all: 'foo-1.0.0.tgz',
327+
stdout: 'foo-1.0.0.tgz',
328+
stderr: '',
329+
});
330+
expect(writtenFiles).toEqual(['fake/foo-1.0.0.tgz']);
331+
expect(registryData).toEqual({});
332+
});
333+
334+
it('packs scoped package', async () => {
335+
const registryData = {};
336+
packageJson = { name: '@foo/bar', version: '2.0.0' };
337+
const result = await _mockNpmPack(registryData, [], { cwd: 'fake' });
338+
expect(result).toEqual({
339+
success: true,
340+
failed: false,
341+
all: 'foo-bar-2.0.0.tgz',
342+
stdout: 'foo-bar-2.0.0.tgz',
343+
stderr: '',
344+
});
345+
expect(writtenFiles).toEqual(['fake/foo-bar-2.0.0.tgz']);
346+
expect(registryData).toEqual({});
347+
});
348+
});
349+
280350
describe('mockNpm', () => {
281351
const npmMock = initNpmMock();
282352
let packageJson: PackageJson | undefined;
@@ -334,7 +404,7 @@ describe('mockNpm', () => {
334404
});
335405

336406
it('throws on unsupported command', async () => {
337-
await expect(() => npm(['pack'], { cwd: undefined })).rejects.toThrow('Command not supported by mock npm: pack');
407+
await expect(() => npm(['foo'], { cwd: undefined })).rejects.toThrow('Command not supported by mock npm: foo');
338408
});
339409

340410
it('respects mocked command', async () => {

src/__fixtures__/mockNpm.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export function initNpmMock(): NpmMock {
8181
const defaultMocks: Record<string, MockNpmCommand> = {
8282
show: _mockNpmShow,
8383
publish: _mockNpmPublish,
84+
pack: _mockNpmPack,
8485
};
8586
let overrideMocks: Record<string, MockNpmCommand> = {};
8687
let registryData: MockNpmRegistry = {};
@@ -233,3 +234,29 @@ function mockPublishPackage(registryData: MockNpmRegistry, packageJson: PackageJ
233234

234235
return `[fake] published ${name}@${version} with tag ${tag}`;
235236
}
237+
238+
/**
239+
* Return a .tgz filename following npm's naming scheme.
240+
*/
241+
export function getMockNpmPackName(packageJson: PackageJson) {
242+
const { name, version } = packageJson;
243+
// Note this may be less name sanitization than npm does, but it doesn't matter for tests.
244+
const safeName = name.startsWith('@') ? name.slice(1).replace('/', '-') : name;
245+
return `${safeName}-${version}.tgz`;
246+
}
247+
248+
export const _mockNpmPack: MockNpmCommand = async (registryData, args, opts) => {
249+
if (!opts?.cwd) {
250+
throw new Error('cwd is required for mock npm pack');
251+
}
252+
253+
// Read package.json from cwd to find the package name and version.
254+
// (If this fails, let the exception propagate for easier debugging.)
255+
const packageJson = fs.readJsonSync(path.join(opts.cwd, 'package.json')) as PackageJson;
256+
257+
// Create a fake ".tgz" file with npm's naming scheme (contents don't matter).
258+
const packFileName = getMockNpmPackName(packageJson);
259+
fs.writeFileSync(path.join(opts.cwd, packFileName), 'fake package contents');
260+
261+
return { stdout: packFileName, stderr: '', all: packFileName, success: true, failed: false };
262+
};

0 commit comments

Comments
 (0)