fix(ngcc): update package.json deterministically (#34870)

Ngcc adds properties to the `package.json` files of the entry-points it
processes to mark them as processed for a format and point to the
created Ivy entry-points (in case of `--create-ivy-entry-points`). When
running ngcc in parallel mode (which is the default for the standalone
ngcc command), multiple formats can be processed simultaneously for the
same entry-point and the order of completion is not deterministic.

Previously, ngcc would append new properties at the end of the target
object in `package.json` as soon as the format processing was completed.
As a result, the order of properties in the resulting `package.json`
(when processing multiple formats for an entry-point in parallel) was
not deterministic. For tools that use file hashes for caching purposes
(such as Bazel), this lead to a high probability of cache misses.

This commit fixes the problem by ensuring that the position of
properties added to `package.json` files is deterministic and
independent of the order in which each format is processed.

Jira issue: [FW-1801](https://angular-team.atlassian.net/browse/FW-1801)

Fixes #34635

PR Close #34870
This commit is contained in:
George Kalpakas
2020-01-20 18:02:12 +02:00
committed by Andrew Kushnir
parent 8215b345e3
commit a10d2a8dc4
7 changed files with 314 additions and 26 deletions

View File

@ -14,7 +14,7 @@ import {absoluteFrom as _} from '../../../../src/ngtsc/file_system';
import {runInEachFileSystem} from '../../../../src/ngtsc/file_system/testing';
import {ClusterPackageJsonUpdater} from '../../../src/execution/cluster/package_json_updater';
import {JsonObject} from '../../../src/packages/entry_point';
import {PackageJsonUpdate, PackageJsonUpdater} from '../../../src/writing/package_json_updater';
import {PackageJsonPropertyPositioning, PackageJsonUpdate, PackageJsonUpdater} from '../../../src/writing/package_json_updater';
import {mockProperty} from '../../helpers/spy_utils';
@ -48,10 +48,18 @@ runInEachFileSystem(() => {
const update = updater.createUpdate();
update.addChange(['foo'], 'updated');
update.addChange(['baz'], 'updated 2', 'alphabetic');
update.addChange(['bar'], 'updated 3', {before: 'bar'});
update.writeChanges(jsonPath);
expect(writeChangesSpy)
.toHaveBeenCalledWith([[['foo'], 'updated']], jsonPath, undefined);
.toHaveBeenCalledWith(
[
[['foo'], 'updated', 'unimportant'],
[['baz'], 'updated 2', 'alphabetic'],
[['bar'], 'updated 3', {before: 'bar'}],
],
jsonPath, undefined);
});
}));
});
@ -65,10 +73,18 @@ runInEachFileSystem(() => {
const jsonPath = _('/foo/package.json');
const parsedJson = {foo: 'bar'};
updater.createUpdate().addChange(['foo'], 'updated').writeChanges(jsonPath, parsedJson);
updater.createUpdate()
.addChange(['foo'], 'updated')
.addChange(['bar'], 'updated too', 'alphabetic')
.writeChanges(jsonPath, parsedJson);
expect(delegate.writeChanges)
.toHaveBeenCalledWith([[['foo'], 'updated']], jsonPath, parsedJson);
.toHaveBeenCalledWith(
[
[['foo'], 'updated', 'unimportant'],
[['bar'], 'updated too', 'alphabetic'],
],
jsonPath, parsedJson);
});
it('should throw, if trying to re-apply an already applied update', () => {
@ -89,21 +105,31 @@ runInEachFileSystem(() => {
it('should send an `update-package-json` message to the master process', () => {
const jsonPath = _('/foo/package.json');
const writeToProp = (propPath: string[], parsed?: JsonObject) =>
updater.createUpdate().addChange(propPath, 'updated').writeChanges(jsonPath, parsed);
const writeToProp =
(propPath: string[], positioning?: PackageJsonPropertyPositioning,
parsed?: JsonObject) => updater.createUpdate()
.addChange(propPath, 'updated', positioning)
.writeChanges(jsonPath, parsed);
writeToProp(['foo']);
expect(processSendSpy).toHaveBeenCalledWith({
type: 'update-package-json',
packageJsonPath: jsonPath,
changes: [[['foo'], 'updated']],
changes: [[['foo'], 'updated', 'unimportant']],
});
writeToProp(['bar', 'baz', 'qux'], {});
writeToProp(['bar'], {before: 'foo'});
expect(processSendSpy).toHaveBeenCalledWith({
type: 'update-package-json',
packageJsonPath: jsonPath,
changes: [[['bar', 'baz', 'qux'], 'updated']],
changes: [[['bar'], 'updated', {before: 'foo'}]],
});
writeToProp(['bar', 'baz', 'qux'], 'alphabetic', {});
expect(processSendSpy).toHaveBeenCalledWith({
type: 'update-package-json',
packageJsonPath: jsonPath,
changes: [[['bar', 'baz', 'qux'], 'updated', 'alphabetic']],
});
});

View File

@ -760,6 +760,79 @@ runInEachFileSystem(() => {
expect(pkg.fesm5_ivy_ngcc).toEqual('__ivy_ngcc__/fesm5/core.js');
expect(pkg.module_ivy_ngcc).toEqual('__ivy_ngcc__/fesm5/core.js');
});
it('should update `package.json` deterministically (regardless of entry-point processing order)',
() => {
// Ensure formats are not marked as processed in `package.json` at the beginning.
let pkg = loadPackage('@angular/core');
expectNotToHaveProp(pkg, 'esm5_ivy_ngcc');
expectNotToHaveProp(pkg, 'fesm2015_ivy_ngcc');
expectNotToHaveProp(pkg, 'fesm5_ivy_ngcc');
expectNotToHaveProp(pkg, '__processed_by_ivy_ngcc__');
// Process `fesm2015` and update `package.json`.
pkg = processFormatAndUpdatePackageJson('fesm2015');
expectNotToHaveProp(pkg, 'esm5_ivy_ngcc');
expectToHaveProp(pkg, 'fesm2015_ivy_ngcc');
expectNotToHaveProp(pkg, 'fesm5_ivy_ngcc');
expectToHaveProp(pkg.__processed_by_ivy_ngcc__ !, 'fesm2015');
// Process `fesm5` and update `package.json`.
pkg = processFormatAndUpdatePackageJson('fesm5');
expectNotToHaveProp(pkg, 'esm5_ivy_ngcc');
expectToHaveProp(pkg, 'fesm2015_ivy_ngcc');
expectToHaveProp(pkg, 'fesm5_ivy_ngcc');
expectToHaveProp(pkg.__processed_by_ivy_ngcc__ !, 'fesm5');
// Process `esm5` and update `package.json`.
pkg = processFormatAndUpdatePackageJson('esm5');
expectToHaveProp(pkg, 'esm5_ivy_ngcc');
expectToHaveProp(pkg, 'fesm2015_ivy_ngcc');
expectToHaveProp(pkg, 'fesm5_ivy_ngcc');
expectToHaveProp(pkg.__processed_by_ivy_ngcc__ !, 'esm5');
// Ensure the properties are in deterministic order (regardless of processing order).
const pkgKeys = stringifyKeys(pkg);
expect(pkgKeys).toContain('|esm5_ivy_ngcc|esm5|');
expect(pkgKeys).toContain('|fesm2015_ivy_ngcc|fesm2015|');
expect(pkgKeys).toContain('|fesm5_ivy_ngcc|fesm5|');
// NOTE:
// Along with the first format that is processed, the typings are processed as well.
// Also, once a property has been processed, alias properties as also marked as
// processed. Aliases properties are properties that point to the same entry-point file.
// For example:
// - `fesm2015` <=> `es2015`
// - `fesm5` <=> `module`
expect(stringifyKeys(pkg.__processed_by_ivy_ngcc__ !))
.toBe('|es2015|esm5|fesm2015|fesm5|module|typings|');
// Helpers
function expectNotToHaveProp(obj: object, prop: string) {
expect(obj.hasOwnProperty(prop))
.toBe(
false,
`Expected object not to have property '${prop}': ${JSON.stringify(obj, null, 2)}`);
}
function expectToHaveProp(obj: object, prop: string) {
expect(obj.hasOwnProperty(prop))
.toBe(
true,
`Expected object to have property '${prop}': ${JSON.stringify(obj, null, 2)}`);
}
function processFormatAndUpdatePackageJson(formatProp: string) {
mainNgcc({
basePath: '/node_modules/@angular/core',
createNewEntryPointFormats: true,
propertiesToConsider: [formatProp],
});
return loadPackage('@angular/core');
}
function stringifyKeys(obj: object) { return `|${Object.keys(obj).join('|')}|`; }
});
});
describe('diagnostics', () => {

View File

@ -8,6 +8,7 @@
import {AbsoluteFsPath, FileSystem, absoluteFrom, getFileSystem} from '../../../src/ngtsc/file_system';
import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing';
import {loadTestFiles} from '../../../test/helpers';
import {JsonObject} from '../../src/packages/entry_point';
import {DirectPackageJsonUpdater, PackageJsonUpdater} from '../../src/writing/package_json_updater';
runInEachFileSystem(() => {
@ -151,5 +152,137 @@ runInEachFileSystem(() => {
expect(() => update.writeChanges(_('/bar/package.json')))
.toThrowError('Trying to apply a `PackageJsonUpdate` that has already been applied.');
});
describe('(property positioning)', () => {
// Helpers
const createJsonFile = (jsonObj: JsonObject) => {
const jsonPath = _('/foo/package.json');
loadTestFiles([{name: jsonPath, contents: JSON.stringify(jsonObj)}]);
return jsonPath;
};
const expectJsonEquals = (jsonFilePath: AbsoluteFsPath, jsonObj: JsonObject) =>
expect(fs.readFile(jsonFilePath).trim()).toBe(JSON.stringify(jsonObj, null, 2));
it('should not change property positioning by default', () => {
const jsonPath = createJsonFile({
p2: '2',
p1: {p12: '1.2', p11: '1.1'},
});
updater.createUpdate()
.addChange(['p1', 'p11'], '1.1-updated')
.addChange(['p1', 'p10'], '1.0-added')
.addChange(['p2'], '2-updated')
.addChange(['p0'], '0-added')
.writeChanges(jsonPath);
expectJsonEquals(jsonPath, {
p2: '2-updated',
p1: {p12: '1.2', p11: '1.1-updated', p10: '1.0-added'},
p0: '0-added',
});
});
it('should not change property positioning with `positioning: unimportant`', () => {
const jsonPath = createJsonFile({
p2: '2',
p1: {p12: '1.2', p11: '1.1'},
});
updater.createUpdate()
.addChange(['p1', 'p11'], '1.1-updated', 'unimportant')
.addChange(['p1', 'p10'], '1.0-added', 'unimportant')
.addChange(['p2'], '2-updated', 'unimportant')
.addChange(['p0'], '0-added', 'unimportant')
.writeChanges(jsonPath);
expectJsonEquals(jsonPath, {
p2: '2-updated',
p1: {p12: '1.2', p11: '1.1-updated', p10: '1.0-added'},
p0: '0-added',
});
});
it('should position added/updated properties alphabetically with `positioning: alphabetic`',
() => {
const jsonPath = createJsonFile({
p2: '2',
p1: {p12: '1.2', p11: '1.1'},
});
updater.createUpdate()
.addChange(['p1', 'p11'], '1.1-updated', 'alphabetic')
.addChange(['p1', 'p10'], '1.0-added', 'alphabetic')
.addChange(['p0'], '0-added', 'alphabetic')
.addChange(['p3'], '3-added', 'alphabetic')
.writeChanges(jsonPath);
expectJsonEquals(jsonPath, {
p0: '0-added',
p2: '2',
p1: {p10: '1.0-added', p11: '1.1-updated', p12: '1.2'},
p3: '3-added',
});
});
it('should position added/updated properties correctly with `positioning: {before: ...}`',
() => {
const jsonPath = createJsonFile({
p2: '2',
p1: {p12: '1.2', p11: '1.1'},
});
updater.createUpdate()
.addChange(['p0'], '0-added', {before: 'p1'})
.addChange(['p1', 'p10'], '1.0-added', {before: 'p11'})
.addChange(['p1', 'p12'], '1.2-updated', {before: 'p11'})
.writeChanges(jsonPath);
expectJsonEquals(jsonPath, {
p2: '2',
p0: '0-added',
p1: {p10: '1.0-added', p12: '1.2-updated', p11: '1.1'},
});
// Verify that trying to add before non-existent property, puts updated property at the
// end.
updater.createUpdate()
.addChange(['p3'], '3-added', {before: 'non-existent'})
.addChange(['p1', 'p10'], '1.0-updated', {before: 'non-existent'})
.writeChanges(jsonPath);
expectJsonEquals(jsonPath, {
p2: '2',
p0: '0-added',
p1: {p12: '1.2-updated', p11: '1.1', p10: '1.0-updated'},
p3: '3-added',
});
});
it('should ignore positioning when updating an in-memory representation', () => {
const jsonObj = {
p20: '20',
p10: {p102: '10.2', p101: '10.1'},
};
const jsonPath = createJsonFile(jsonObj);
updater.createUpdate()
.addChange(['p0'], '0-added', 'alphabetic')
.addChange(['p1'], '1-added', 'unimportant')
.addChange(['p2'], '2-added')
.addChange(['p20'], '20-updated', 'alphabetic')
.addChange(['p10', 'p103'], '10.3-added', {before: 'p102'})
.addChange(['p10', 'p102'], '10.2-updated', {before: 'p103'})
.writeChanges(jsonPath, jsonObj);
expect(JSON.stringify(jsonObj)).toBe(JSON.stringify({
p20: '20-updated',
p10: {p102: '10.2-updated', p101: '10.1', p103: '10.3-added'},
p0: '0-added',
p1: '1-added',
p2: '2-added',
}));
});
});
});
});