Skip to content

Commit e9d6892

Browse files
committed
More publishing tests + logging improvements
1 parent f0529da commit e9d6892

17 files changed

+735
-303
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Improve publish logging",
4+
"packageName": "beachball",
5+
"email": "elcraig@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

src/__e2e__/publishE2E.test.ts

Lines changed: 15 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
1-
import { describe, expect, it, afterEach, jest } from '@jest/globals';
1+
import { afterEach, describe, expect, it, jest } from '@jest/globals';
22
import fs from 'fs';
33
import path from 'path';
44
import { addGitObserver, catalogsToYaml, clearGitObservers, type Catalogs } from 'workspace-tools';
55
import { generateChangeFiles, getChangeFiles } from '../__fixtures__/changeFiles';
66
import { defaultBranchName, defaultRemoteBranchName } from '../__fixtures__/gitDefaults';
77
import { initMockLogs } from '../__fixtures__/mockLogs';
8+
import { _mockNpmPublish, initNpmMock } from '../__fixtures__/mockNpm';
9+
import { deepFreezeProperties } from '../__fixtures__/object';
810
import type { Repository } from '../__fixtures__/repository';
9-
import { type PackageJsonFixture, type RepoFixture, RepositoryFactory } from '../__fixtures__/repositoryFactory';
11+
import { RepositoryFactory, type RepoFixture } from '../__fixtures__/repositoryFactory';
1012
import { publish } from '../commands/publish';
13+
import { getPackageInfos } from '../monorepo/getPackageInfos';
14+
import { readJson } from '../object/readJson';
15+
import { getParsedOptions } from '../options/getOptions';
1116
import type { ParsedOptions, RepoOptions } from '../types/BeachballOptions';
12-
import { _mockNpmPublish, initNpmMock } from '../__fixtures__/mockNpm';
1317
import type { PackageJson } from '../types/PackageInfo';
14-
import { getParsedOptions } from '../options/getOptions';
15-
import { getPackageInfos } from '../monorepo/getPackageInfos';
1618
import { validate } from '../validation/validate';
17-
import { readJson } from '../object/readJson';
18-
import { createCommandContext } from '../monorepo/createCommandContext';
19-
import { deepFreezeProperties } from '../__fixtures__/object';
2019

20+
// These tests are slow, so they should only cover E2E publishing scenarios that can't be fully
21+
// covered by lower-level tests (such as publishToRegistry functional tests), and a few all-up
22+
// scenarios as sanity checks. Tests specific to git or npm scenarios should potentially go in
23+
// publishGit.test.ts or publishRegistry.test.ts isntead.
24+
//
2125
// Spawning actual npm to run commands against a fake registry is extremely slow, so mock it for
2226
// this test (packagePublish covers the more complete npm registry scenario).
2327
//
@@ -33,7 +37,7 @@ describe('publish command (e2e)', () => {
3337
let repo: Repository | undefined;
3438

3539
// show error logs for these tests
36-
const logs = initMockLogs({ alsoLog: ['error'] });
40+
initMockLogs({ alsoLog: ['error'] });
3741

3842
function getOptions(repoOptions?: Partial<RepoOptions>, extraArgv?: string[]) {
3943
const parsedOptions = getParsedOptions({
@@ -406,7 +410,8 @@ describe('publish command (e2e)', () => {
406410
});
407411
});
408412

409-
// These tests are slow, so combine pre and post hooks
413+
// These tests are slow, so combine pre and post hooks.
414+
// This needs to be an E2E test to verify the versions etc passed through are correct.
410415
it('respects prepublish/postpublish hooks', async () => {
411416
repositoryFactory = new RepositoryFactory('monorepo');
412417
repo = repositoryFactory.cloneRepository();
@@ -471,162 +476,4 @@ describe('publish command (e2e)', () => {
471476
expect(fooJsonPost.customOnPublish?.main).toBe('lib/index.js');
472477
expect(notified).toBe(fooJsonPost.customAfterPublish?.notify);
473478
});
474-
475-
it('respects concurrency limit when publishing multiple packages', async () => {
476-
const packagesToPublish = ['pkg1', 'pkg2', 'pkg3', 'pkg4', 'pkg5'];
477-
const packages: { [packageName: string]: PackageJsonFixture } = {};
478-
for (const name of packagesToPublish) {
479-
packages[name] = { version: '1.0.0' };
480-
}
481-
482-
repositoryFactory = new RepositoryFactory({
483-
folders: {
484-
packages: packages,
485-
},
486-
});
487-
repo = repositoryFactory.cloneRepository();
488-
489-
// Skip fetching and pushing since it's slow and not important for this test
490-
const concurrency = 2;
491-
const { options, parsedOptions } = getOptions({ concurrency, fetch: false, push: false });
492-
generateChangeFiles(packagesToPublish, options);
493-
494-
const simulateWait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
495-
496-
let currentConcurrency = 0;
497-
let maxConcurrency = 0;
498-
npmMock.setCommandOverride('publish', async (registryData, args, opts) => {
499-
currentConcurrency++;
500-
await simulateWait(100);
501-
const result = await _mockNpmPublish(registryData, args, opts);
502-
maxConcurrency = Math.max(maxConcurrency, currentConcurrency);
503-
currentConcurrency--;
504-
return result;
505-
});
506-
507-
// skip validate for this test since it's not relevant
508-
await publish(options, createCommandContext(parsedOptions));
509-
// Verify that at most `concurrency` number of packages were published concurrently
510-
expect(maxConcurrency).toBe(concurrency);
511-
512-
// Verify all packages were published
513-
for (const pkg of packagesToPublish) {
514-
expect(npmMock.getPublishedVersions(pkg)).toEqual({
515-
versions: ['1.1.0'],
516-
'dist-tags': { latest: '1.1.0' },
517-
});
518-
}
519-
});
520-
521-
it('handles errors correctly when one package fails during concurrent publishing', async () => {
522-
logs.setOverrideOptions({ alsoLog: [] });
523-
const packageNames = ['pkg1', 'pkg2', 'pkg3', 'pkg4', 'pkg5'];
524-
const packages: { [packageName: string]: PackageJsonFixture } = {};
525-
const packageToFail = 'pkg3';
526-
for (const name of packageNames) {
527-
packages[name] = { version: '1.0.0' };
528-
}
529-
packages['pkg1'].dependencies = { [packageToFail]: '1.0.0' };
530-
packages['pkg2'].dependencies = { [packageToFail]: '1.0.0' };
531-
532-
repositoryFactory = new RepositoryFactory({
533-
folders: { packages },
534-
});
535-
repo = repositoryFactory.cloneRepository();
536-
537-
const { options, parsedOptions } = getOptions({
538-
concurrency: 3,
539-
// Skip fetching and pushing since it's slow and not important for this test
540-
fetch: false,
541-
push: false,
542-
});
543-
generateChangeFiles(packageNames, options);
544-
545-
npmMock.setCommandOverride('publish', async (registryData, args, opts) => {
546-
if (opts.cwd?.endsWith(packageToFail)) {
547-
const stderr = 'Failed to publish package';
548-
return { failed: true, stderr, stdout: '', success: false, all: stderr };
549-
}
550-
return _mockNpmPublish(registryData, args, opts);
551-
});
552-
553-
// skip validate for this test since it's not relevant
554-
await expect(publish(options, createCommandContext(parsedOptions))).rejects.toThrow(
555-
'Error publishing! Refer to the previous logs for recovery instructions.'
556-
);
557-
558-
for (const name of packageNames) {
559-
if (['pkg1', 'pkg2', packageToFail].includes(name)) {
560-
// Verify that the packages that failed to publish are not published.
561-
// pkg1 and pkg2 are not published because they depend on pkg3, which failed to publish.
562-
expect(npmMock.getPublishedVersions(name)).toBeUndefined();
563-
} else {
564-
// Verify that the packages that did not fail to publish are published
565-
expect(npmMock.getPublishedVersions(name)).toEqual({
566-
versions: ['1.1.0'],
567-
'dist-tags': { latest: '1.1.0' },
568-
});
569-
}
570-
}
571-
});
572-
573-
// Just test postpublish (prepublish should have the same logic)
574-
// TODO: possibly move to in-memory test
575-
it('respects concurrency limit for publish hooks', async () => {
576-
const packagesToPublish = ['pkg1', 'pkg2', 'pkg3', 'pkg4'];
577-
type ExtraPackageJsonFixture = PackageJsonFixture & { customAfterPublish?: { notify: string } };
578-
const packages: { [packageName: string]: ExtraPackageJsonFixture } = {};
579-
for (const name of packagesToPublish) {
580-
packages[name] = {
581-
version: '1.0.0',
582-
customAfterPublish: {
583-
notify: `message-${name}`,
584-
},
585-
};
586-
}
587-
588-
repositoryFactory = new RepositoryFactory({
589-
folders: { packages },
590-
});
591-
repo = repositoryFactory.cloneRepository();
592-
593-
const simulateWait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
594-
595-
const afterPublishStrings: Record<string, string | undefined> = {};
596-
const concurrency = 2;
597-
let currentConcurrency = 0;
598-
let maxConcurrency = 0;
599-
const { options, parsedOptions } = getOptions({
600-
hooks: {
601-
postpublish: async (packagePath, name) => {
602-
currentConcurrency++;
603-
await simulateWait(100);
604-
const packageJsonPath = path.join(packagePath, 'package.json');
605-
const packageJson = readJson<ExtraPackageJsonFixture>(packageJsonPath);
606-
afterPublishStrings[name] = packageJson.customAfterPublish?.notify;
607-
maxConcurrency = Math.max(maxConcurrency, currentConcurrency);
608-
currentConcurrency--;
609-
},
610-
},
611-
concurrency,
612-
// Skip fetching and pushing since it's slow and not important for this test
613-
fetch: false,
614-
push: false,
615-
});
616-
617-
generateChangeFiles(packagesToPublish, options);
618-
619-
// skip validate for this test since it's not relevant
620-
await publish(options, createCommandContext(parsedOptions));
621-
// Verify that at most `concurrency` number of postpublish hooks were running concurrently
622-
expect(maxConcurrency).toBe(concurrency);
623-
624-
for (const pkg of packagesToPublish) {
625-
const packageJson = readJson<ExtraPackageJsonFixture>(repo.pathTo(`packages/${pkg}/package.json`));
626-
if (packageJson.customAfterPublish) {
627-
// Verify that all postpublish hooks were called
628-
expect(afterPublishStrings[pkg]).toEqual(packageJson.customAfterPublish.notify);
629-
}
630-
}
631-
});
632479
});

src/__e2e__/publishRegistry.test.ts

Lines changed: 18 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
1-
import { describe, expect, it, afterEach, jest } from '@jest/globals';
1+
import { afterEach, describe, expect, it, jest } from '@jest/globals';
22
import fs from 'fs';
3-
import { defaultRemoteBranchName } from '../__fixtures__/gitDefaults';
43
import { generateChangeFiles } from '../__fixtures__/changeFiles';
4+
import { defaultRemoteBranchName } from '../__fixtures__/gitDefaults';
55
import { initMockLogs } from '../__fixtures__/mockLogs';
6+
import { initNpmMock } from '../__fixtures__/mockNpm';
7+
import { deepFreezeProperties } from '../__fixtures__/object';
68
import type { Repository } from '../__fixtures__/repository';
79
import { RepositoryFactory } from '../__fixtures__/repositoryFactory';
8-
import { publish } from '../commands/publish';
9-
import type { ParsedOptions, RepoOptions } from '../types/BeachballOptions';
10-
import { initNpmMock } from '../__fixtures__/mockNpm';
1110
import { removeTempDir, tmpdir } from '../__fixtures__/tmpdir';
12-
import { getParsedOptions } from '../options/getOptions';
11+
import { publish } from '../commands/publish';
1312
import { createCommandContext } from '../monorepo/createCommandContext';
13+
import { getParsedOptions } from '../options/getOptions';
14+
import type { ParsedOptions, RepoOptions } from '../types/BeachballOptions';
1415
import { validate } from '../validation/validate';
15-
import { deepFreezeProperties } from '../__fixtures__/object';
1616

17+
// These are E2E tests for publish() relating specifically to the npm parts.
18+
// (Lower-level tests for publishToRegistry() are in __functional__/publish/publishToRegistry.test.ts.)
19+
//
1720
// Spawning actual npm to run commands against a fake registry is extremely slow, so mock it for
1821
// this test (packagePublish covers the more complete npm registry scenario).
1922
//
@@ -76,6 +79,7 @@ describe('publish command (registry)', () => {
7679
packToPath = undefined;
7780
});
7881

82+
// One little sanity check for packToPath (it's mostly covered by lower-level tests)
7983
it('packs single package', async () => {
8084
repositoryFactory = new RepositoryFactory('single');
8185
repo = repositoryFactory.cloneRepository();
@@ -151,19 +155,20 @@ describe('publish command (registry)', () => {
151155
152156
[log] Removing change files:
153157
[log] - publicpkg-<guid>.json
154-
[log]
155-
Validating new package versions...
156-
[log]
157-
Package versions are OK to publish:
158+
[log] Validating new package versions...
159+
160+
[log] Package versions are OK to publish:
158161
• publicpkg@1.1.0
162+
159163
[log] Validating no private package among package dependencies
160164
[log] OK!
161165
162-
[log]
163-
Publishing - publicpkg@1.1.0 with tag latest
166+
[log] Publishing - publicpkg@1.1.0 with tag latest
164167
[log] publish command: publish --registry fake --tag latest --loglevel warn
165168
[log] (cwd: <root>/packages/publicpkg)
169+
166170
[log] Published! - publicpkg@1.1.0
171+
167172
[log]
168173
[log] Skipping git push and tagging
169174
[log]
@@ -193,34 +198,6 @@ describe('publish command (registry)', () => {
193198
expect(logs.mocks.error).not.toHaveBeenCalled();
194199
});
195200

196-
it('packs many packages', async () => {
197-
const packageNames = Array.from({ length: 11 }, (_, i) => `pkg-${i + 1}`);
198-
repositoryFactory = new RepositoryFactory({
199-
folders: {
200-
packages: Object.fromEntries(
201-
packageNames.map((name, i) => [
202-
name,
203-
// Each package depends on the next one, so they must be published in reverse alphabetical order
204-
{ version: '1.0.0', dependencies: { [packageNames[i + 1] || 'other']: '^1.0.0' } },
205-
])
206-
),
207-
},
208-
});
209-
repo = repositoryFactory.cloneRepository();
210-
packToPath = tmpdir({ prefix: 'beachball-pack-' });
211-
212-
const { options, parsedOptions } = getOptions({ packToPath, groupChanges: true });
213-
generateChangeFiles(packageNames, options);
214-
// initial validate() isn't relevant here
215-
await publish(options, createCommandContext(parsedOptions));
216-
217-
expect(fs.readdirSync(packToPath).sort()).toEqual(
218-
[...packageNames].reverse().map((name, i) => `${String(i + 1).padStart(2, '0')}-${name}-1.1.0.tgz`)
219-
);
220-
expect(npmMock.getPublishedVersions('pkg-1')).toBeUndefined();
221-
expect(logs.mocks.error).not.toHaveBeenCalled();
222-
});
223-
224201
it('exits publishing early if only invalid change files exist', async () => {
225202
repositoryFactory = new RepositoryFactory('monorepo');
226203
repo = repositoryFactory.cloneRepository();
@@ -242,31 +219,4 @@ describe('publish command (registry)', () => {
242219

243220
expect(npmMock.getPublishedVersions('foo')).toBeUndefined();
244221
});
245-
246-
it('errors if version already exists in registry', async () => {
247-
repositoryFactory = new RepositoryFactory('monorepo');
248-
repo = repositoryFactory.cloneRepository();
249-
// Simulate the current package versions already existing to test validatePackageVersions
250-
npmMock.publishPackage(repositoryFactory.fixture.folders.packages.foo);
251-
npmMock.publishPackage(repositoryFactory.fixture.folders.packages.bar);
252-
npmMock.publishPackage(repositoryFactory.fixture.folders.packages.baz);
253-
// also say the bumped version of foo and bar already exist (baz is fine)
254-
npmMock.publishPackage({ ...repositoryFactory.fixture.folders.packages.foo, version: '1.1.0' });
255-
npmMock.publishPackage({ ...repositoryFactory.fixture.folders.packages.bar, version: '1.4.0' });
256-
257-
const { options, parsedOptions } = getOptions();
258-
generateChangeFiles(['foo', 'bar', 'baz'], options);
259-
await expect(() => publishWrapper(parsedOptions)).rejects.toThrow('process.exit');
260-
261-
expect(logs.getMockLines('error')).toMatchInlineSnapshot(`
262-
"ERROR: Attempting to publish package versions that already exist in the registry:
263-
• bar@1.4.0
264-
• foo@1.1.0
265-
Something went wrong with publishing! Manually update these package and versions:
266-
• bar@1.4.0
267-
• baz@1.4.0
268-
• foo@1.1.0
269-
No packages were published due to validation errors (see above for details)."
270-
`);
271-
});
272222
});

src/__fixtures__/mockLogs.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export type MockLogs = {
2828
opts?: {
2929
/** Replace this path with `<root>` and normalize slashes */
3030
root?: string;
31+
/** Mapping from path to placeholder text, e.g. `{ [packToPath]: '<packPath>' }` */
32+
replacePaths?: Record<string, string>;
3133
/**
3234
* Sanitize GUIDs, full commit hashes, and publish branch timestamps.
3335
*
@@ -74,10 +76,14 @@ export function initMockLogs(options: MockLogsOptions = {}): MockLogs {
7476
.join('\n')
7577
.trim();
7678

77-
if (opts.root) {
79+
if (opts.root || opts.replacePaths) {
80+
const replacePaths = { ...opts.replacePaths, ...(opts.root && { [opts.root]: '<root>' }) };
7881
// Normalize slashes first to ensure they're the same, then emulate replaceAll
79-
lines = lines.replace(/\\/g, '/').split(opts.root.replace(/\\/g, '/')).join('<root>');
82+
for (const [key, value] of Object.entries(replacePaths)) {
83+
lines = lines.split(key.replace(/\\/g, '/')).join(value);
84+
}
8085
}
86+
8187
if (opts.sanitize) {
8288
lines = lines
8389
// Replace GUIDs with <guid>

0 commit comments

Comments
 (0)