Testing Symbol Propagation and Resolution in Parcel Bundler
This test suite validates symbol propagation functionality in Parcel’s core asset graph system. It verifies how symbols are tracked, propagated and resolved between dependencies and assets during bundling.
Test Coverage Overview
Implementation Analysis
Technical Details
Best Practices Demonstrated
parcel-bundler/parcel
packages/core/core/test/SymbolPropagation.test.js
// @flow strict-local
import assert from 'assert';
import invariant from 'assert';
import nullthrows from 'nullthrows';
import type {FilePath, SourceLocation, Meta, Symbol} from '@parcel/types';
import type {ContentKey, NodeId} from '@parcel/graph';
import type {Diagnostic} from '@parcel/diagnostic';
import ThrowableDiagnostic from '@parcel/diagnostic';
import {setEqual} from '@parcel/utils';
import AssetGraph, {
nodeFromAssetGroup,
nodeFromDep,
nodeFromEntryFile,
nodeFromAsset,
} from '../src/AssetGraph';
import {createDependency as _createDependency} from '../src/Dependency';
import {createAsset as _createAsset} from '../src/assetUtils';
import {
toProjectPath as _toProjectPath,
type ProjectPath,
} from '../src/projectPath';
import {propagateSymbols} from '../src/SymbolPropagation';
import dumpGraphToGraphViz from '../src/dumpGraphToGraphViz';
import {DEFAULT_ENV, DEFAULT_OPTIONS, DEFAULT_TARGETS} from './test-utils';
import type {
Asset,
AssetNode,
AssetGraphNode,
Dependency,
DependencyNode,
} from '../src/types';
const stats = {size: 0, time: 0};
function createAsset(opts) {
return _createAsset('/', opts);
}
function createDependency(opts) {
return _createDependency('/', opts);
}
function toProjectPath(p) {
return _toProjectPath('/', p);
}
function fromProjectPathUnix(p: ProjectPath) {
// $FlowFixMe
return '/' + p;
}
function nullthrowsAssetNode(v: ?AssetGraphNode): AssetNode {
invariant(v?.type === 'asset');
return v;
}
function nullthrowsDependencyNode(v: ?AssetGraphNode): DependencyNode {
invariant(v?.type === 'dependency');
return v;
}
function createAssetGraph(
assets: Array<
[
FilePath,
/* symbols (or cleared) */ ?Array<
[Symbol, {|local: Symbol, loc?: ?SourceLocation, meta?: ?Meta|}],
>,
/* sideEffects */ boolean,
],
>,
dependencies: Array<
[
/* from */ FilePath,
/* to */ FilePath,
/* symbols (or cleared) */ ?Array<
[
Symbol,
{|
local: Symbol,
loc?: ?SourceLocation,
isWeak: boolean,
meta?: ?Meta,
|},
],
>,
],
>,
isLibrary?: boolean,
) {
let graph = new AssetGraph();
let entryFilePath = '/index.js';
graph.setRootConnections({
entries: [toProjectPath(entryFilePath)],
});
let entry = {
filePath: toProjectPath(entryFilePath),
packagePath: toProjectPath('/'),
};
let entryNodeContentKey = nodeFromEntryFile(entry).id;
graph.resolveEntry(toProjectPath(entryFilePath), [entry], '1');
graph.resolveTargets(entry, DEFAULT_TARGETS, '2');
let entryDependencyId = graph.getNodeIdsConnectedFrom(
graph.getNodeIdByContentKey(entryNodeContentKey),
)[0];
if (isLibrary) {
let entryDependencyNode = nullthrows(graph.getNode(entryDependencyId));
invariant(entryDependencyNode.type === 'dependency');
entryDependencyNode.value.symbols = new Map([
['*', {local: '*', isWeak: true, loc: null}],
]);
entryDependencyNode.usedSymbolsDown.add('*');
entryDependencyNode.usedSymbolsUp.set('*', undefined);
}
let assetId = 1;
let changedAssets = new Map();
let assetGroupNodes = new Map<FilePath, NodeId>();
let assetNodes = new Map<FilePath, NodeId>();
for (let [filePath, symbols, sideEffects] of assets) {
let assetGroup = nodeFromAssetGroup({
filePath: toProjectPath(filePath),
env: DEFAULT_ENV,
sideEffects,
});
let assetGroupNodeId = graph.addNodeByContentKey(assetGroup.id, assetGroup);
assetGroupNodes.set(filePath, assetGroupNodeId);
let asset = nodeFromAsset(
createAsset({
id: String(assetId),
filePath: toProjectPath(filePath),
type: 'js',
isSource: true,
sideEffects,
stats,
symbols: symbols ? new Map(symbols) : symbols,
env: DEFAULT_ENV,
}),
);
let assetNodeId = graph.addNodeByContentKey(asset.id, asset);
assetNodes.set(filePath, assetNodeId);
changedAssets.set(String(assetId), asset.value);
graph.addEdge(assetGroupNodeId, assetNodeId);
assetId++;
}
for (let [from, to, symbols] of dependencies) {
let dependencyNode = nodeFromDep(
createDependency({
specifier: to,
specifierType: 'esm',
env: DEFAULT_ENV,
symbols: symbols ? new Map(symbols) : symbols,
sourcePath: from,
sourceAssetId: from,
}),
);
let dependencyNodeId = graph.addNodeByContentKey(
dependencyNode.id,
dependencyNode,
);
graph.addEdge(nullthrows(assetNodes.get(from)), dependencyNodeId);
graph.addEdge(dependencyNodeId, nullthrows(assetGroupNodes.get(to)));
}
let entryAssetGroup = nullthrows(assetGroupNodes.get(entryFilePath));
graph.addEdge(entryDependencyId, entryAssetGroup);
return {graph, changedAssets};
}
function assertUsedSymbols(
graph: AssetGraph,
_expectedAsset: Array<[FilePath, /* usedSymbols */ Array<Symbol>]>,
_expectedDependency: Array<
[
FilePath,
FilePath,
/* usedSymbols */ Array<[Symbol, ?[FilePath, ?Symbol]] | [Symbol]> | null,
],
>,
isLibrary?: boolean,
) {
let expectedAsset = new Map(
_expectedAsset.map(([f, symbols]) => [f, symbols]),
);
let expectedDependency = new Map(
_expectedDependency.map(([from, to, sym]) => [
from + ':' + to,
// $FlowFixMe[invalid-tuple-index]
sym ? sym.map(v => [v[0], v[1] ?? [to, v[0]]]) : sym,
]),
);
if (isLibrary) {
let entryDep = nullthrows(
[...graph.nodes.values()].find(
n => n?.type === 'dependency' && n.value.sourceAssetId == null,
),
);
invariant(entryDep.type === 'dependency');
assertDependencyUsedSymbols(
entryDep.usedSymbolsUp,
new Map([['*', undefined]]),
'entryDep',
);
}
function assertDependencyUsedSymbols(usedSymbolsUp, expectedMap, id) {
assertSetEqual(
new Set(usedSymbolsUp.keys()),
new Set(expectedMap.keys()),
id,
);
for (let [s, resolved] of usedSymbolsUp) {
let exp = expectedMap.get(s);
if (resolved && exp) {
let asset = nullthrows(graph.getNodeByContentKey(resolved.asset));
invariant(asset.type === 'asset');
assert.strictEqual(
fromProjectPathUnix(asset.value.filePath),
exp[0],
`dep ${id}@${s} resolved asset: ${fromProjectPathUnix(
asset.value.filePath,
)} !== ${exp[0]}`,
);
assert.strictEqual(
resolved.symbol,
exp[1],
`dep ${id}@${s} resolved symbol: ${String(
resolved.symbol,
)} !== ${String(exp[1])}`,
);
} else {
assert.equal(resolved, exp);
}
}
}
for (let [nodeId, node] of graph.nodes.entries()) {
if (node?.type === 'asset') {
let filePath = fromProjectPathUnix(node.value.filePath);
let expected = new Set(nullthrows(expectedAsset.get(filePath)));
assertSetEqual(node.usedSymbols, expected, filePath);
} else if (node?.type === 'dependency' && node.value.sourcePath != null) {
let resolutionId = graph.getNodeIdsConnectedFrom(nodeId)[0];
let resolution = nullthrows(graph.getNode(resolutionId));
invariant(resolution.type === 'asset_group');
let to = resolution.value.filePath;
let id =
fromProjectPathUnix(nullthrows(node.value.sourcePath)) +
':' +
fromProjectPathUnix(nullthrows(to));
let expected = expectedDependency.get(id);
if (!expected) {
assert(expected === null);
assertSetEqual(new Set(node.usedSymbolsUp.keys()), new Set(), id);
assert(node.excluded, `${id} should be excluded`);
} else {
assert(!node.excluded, `${id} should not be excluded`);
let expectedMap = new Map(expected);
assertDependencyUsedSymbols(node.usedSymbolsUp, expectedMap, id);
}
}
}
}
function assertSetEqual<T>(
actual: $ReadOnlySet<T>,
expected: $ReadOnlySet<T>,
prefix?: string = '',
) {
assert(
setEqual(actual, expected),
`${prefix} [${[...actual].join(',')}] wasn't [${[...expected].join(',')}]`,
);
}
async function testPropagation(
assets: Array<
[
FilePath,
/* symbols (or cleared) */ ?Array<
[Symbol, {|local: Symbol, loc?: ?SourceLocation, meta?: ?Meta|}],
>,
/* sideEffects */ boolean,
/* usedSymbols */ Array<Symbol>,
],
>,
dependencies: Array<
[
/* from */ FilePath,
/* to */ FilePath,
/* symbols (or cleared) */ ?Array<
[
Symbol,
{|
local: Symbol,
loc?: ?SourceLocation,
isWeak: boolean,
meta?: ?Meta,
|},
],
>,
/* usedSymbols */ Array<
[Symbol, ?[FilePath, ?Symbol]] | [Symbol],
> | /* excluded */ null,
],
>,
isLibrary?: boolean,
): Promise<AssetGraph> {
let {graph, changedAssets} = createAssetGraph(
assets.map(([f, symbols, sideEffects]) => [f, symbols, sideEffects]),
dependencies.map(([from, to, symbols]) => [from, to, symbols]),
isLibrary,
);
await dumpGraphToGraphViz(graph, 'test_before');
handlePropagationErrors(
propagateSymbols({
options: DEFAULT_OPTIONS,
assetGraph: graph,
changedAssetsPropagation: new Set(changedAssets.keys()),
assetGroupsWithRemovedParents: new Set(),
previousErrors: undefined,
}),
);
await dumpGraphToGraphViz(graph, 'test_after');
assertUsedSymbols(
graph,
assets.map(([f, , , usedSymbols]) => [f, usedSymbols]),
dependencies.map(([from, to, , usedSymbols]) => [from, to, usedSymbols]),
isLibrary,
);
return graph;
}
function handlePropagationErrors(errors: Map<NodeId, Array<Diagnostic>>) {
if (errors.size > 0) {
throw new ThrowableDiagnostic({
diagnostic: [...errors.values()][0],
});
}
}
function assertPropagationErrors(
graph: AssetGraph,
actual: Map<NodeId, Array<Diagnostic>>,
expected: Iterable<[FilePath, Array<Diagnostic>]>,
) {
assert.deepEqual(
[...actual].map(([k, v]) => [
nullthrowsAssetNode(graph.getNode(k)).value.filePath,
v,
]),
[...expected],
);
}
function changeDependency(
graph: AssetGraph,
from: FilePath,
to: FilePath,
cb: ($NonMaybeType<Dependency['symbols']>) => void,
): Iterable<[ContentKey, Asset]> {
let sourceAssetNode = nullthrowsAssetNode(
[...graph.nodes.values()].find(
n => n?.type === 'asset' && n.value.filePath === from,
),
);
sourceAssetNode.usedSymbolsDownDirty = true;
let depNode = nullthrowsDependencyNode(
[...graph.nodes.values()].find(
n =>
n?.type === 'dependency' &&
n.value.sourcePath === from &&
n.value.specifier === to,
),
);
cb(nullthrows(depNode.value.symbols));
return [[sourceAssetNode.id, sourceAssetNode.value]];
}
function changeAsset(
graph: AssetGraph,
asset: FilePath,
cb: ($NonMaybeType<Asset['symbols']>) => void,
): Iterable<[ContentKey, Asset]> {
let node = nullthrowsAssetNode(
[...graph.nodes.values()].find(
n => n?.type === 'asset' && n.value.filePath === asset,
),
);
node.usedSymbolsUpDirty = true;
node.usedSymbolsDownDirty = true;
cb(nullthrows(node.value.symbols));
return [[node.id, node.value]];
}
// process.env.PARCEL_DUMP_GRAPHVIZ = '';
// process.env.PARCEL_DUMP_GRAPHVIZ = 'symbols';
describe('SymbolPropagation', () => {
it('basic tree', async () => {
// prettier-ignore
await testPropagation(
[
['/index.js', [], true, []],
['/lib.js', [['f', {local: 'lib1$foo'}], ['b', {local: 'lib2$bar'}]], false, []],
['/lib1.js', [['foo', {local: 'v'}]], false, ['foo']],
['/lib2.js', [['bar', {local: 'v'}]], false, []],
],
[
['/index.js', '/lib.js', [['f', {local: 'f', isWeak: false}]], [['f', ['/lib1.js', 'foo']]]],
['/lib.js', '/lib1.js', [['foo', {local: 'lib1$foo', isWeak: true}]], [['foo']]],
['/lib.js', '/lib2.js', [['bar', {local: 'lib2$bar', isWeak: true}]], null],
],
);
});
it('basic tree - dependency symbol change export', async () => {
// prettier-ignore
let graph = await testPropagation(
[
['/index.js', [], true, []],
['/lib.js', [['f', {local: 'f'}], ['b', {local: 'b'}]], true, ['f']],
],
[
['/index.js', '/lib.js', [['f', {local: 'f', isWeak: false}]], [['f']]],
],
);
let changedAssets = [
...changeDependency(graph, 'index.js', '/lib.js', symbols => {
symbols.set('b', {
local: 'b',
isWeak: false,
loc: undefined,
});
}),
];
propagateSymbols({
options: DEFAULT_OPTIONS,
assetGraph: graph,
changedAssetsPropagation: new Set(new Map(changedAssets).keys()),
assetGroupsWithRemovedParents: new Set(),
});
// prettier-ignore
assertUsedSymbols(graph,
[
['/index.js', []],
['/lib.js', ['f', 'b']],
],
[
['/index.js', '/lib.js', [['f'], ['b']]],
],
);
});
it('basic tree - dependency symbol change import and error', async () => {
// prettier-ignore
let graph = await testPropagation(
[
['/index.js', [], true, []],
['/lib.js', [['f', {local: 'f'}]], true, ['f']],
],
[
['/index.js', '/lib.js', [['f', {local: 'f', isWeak: false}]], [['f']]],
],
);
let changedAssets = [
...changeDependency(graph, 'index.js', '/lib.js', symbols => {
symbols.delete('f');
symbols.set('f2', {
local: 'f2',
isWeak: false,
loc: undefined,
});
}),
];
let errors = propagateSymbols({
options: DEFAULT_OPTIONS,
assetGraph: graph,
changedAssetsPropagation: new Set(new Map(changedAssets).keys()),
assetGroupsWithRemovedParents: new Set(),
});
assertPropagationErrors(graph, errors, [
[
'lib.js',
[
{
message: "lib.js does not export 'f2'",
origin: '@parcel/core',
codeFrames: undefined,
},
],
],
]);
});
it('basic tree - asset symbol change export and error', async () => {
// prettier-ignore
let graph = await testPropagation(
[
['/index.js', [], true, []],
['/lib.js', [['f', {local: 'f'}]], true, ['f']],
],
[
['/index.js', '/lib.js', [['f', {local: 'f', isWeak: false}]], [['f']]],
],
);
let changedAssets = [
...changeAsset(graph, 'lib.js', symbols => {
symbols.delete('f');
symbols.set('f2', {
local: 'f2',
loc: undefined,
});
}),
];
let errors = propagateSymbols({
options: DEFAULT_OPTIONS,
assetGraph: graph,
changedAssetsPropagation: new Set(new Map(changedAssets).keys()),
assetGroupsWithRemovedParents: new Set(),
});
assertPropagationErrors(graph, errors, [
[
'lib.js',
[
{
message: "lib.js does not export 'f'",
origin: '@parcel/core',
codeFrames: undefined,
},
],
],
]);
});
it('basic tree - dependency symbol change reexport', async () => {
// prettier-ignore
let graph = await testPropagation(
[
['/index.js', [], true, []],
['/lib.js', [['f', {local: 'lib1$foo'}], ['b', {local: 'lib2$bar'}]], true, []],
['/lib1.js', [['foo', {local: 'v'}]], true, ['foo']],
['/lib2.js', [['bar', {local: 'v'}]], true, []],
],
[
['/index.js', '/lib.js', [['f', {local: 'f', isWeak: false}]], [['f']]],
['/lib.js', '/lib1.js', [['foo', {local: 'lib1$foo', isWeak: true}]], [['foo']]],
['/lib.js', '/lib2.js', [['bar', {local: 'lib2$bar', isWeak: true}]], []],
],
);
let changedAssets = [
...changeDependency(graph, 'index.js', '/lib.js', symbols => {
symbols.set('b', {
local: 'b',
isWeak: false,
loc: undefined,
});
}),
];
propagateSymbols({
options: DEFAULT_OPTIONS,
assetGraph: graph,
changedAssetsPropagation: new Set(new Map(changedAssets).keys()),
assetGroupsWithRemovedParents: new Set(),
});
// prettier-ignore
assertUsedSymbols(graph,
[
['/index.js', []],
['/lib.js', []],
['/lib1.js', ['foo']],
['/lib2.js', ['bar']],
],
[
['/index.js', '/lib.js', [['f'],['b']]],
['/lib.js', '/lib1.js', [['foo']]],
['/lib.js', '/lib2.js', [['bar']]],
],
);
});
it('basic tree with reexport-all', async () => {
// prettier-ignore
await testPropagation(
[
['/index.js', [], true, []],
['/lib.js', [], false, []],
['/lib1.js', [['foo', {local: 'v'}]], false, ['foo']],
['/lib2.js', [['bar', {local: 'v'}]], false, []],
],
[
['/index.js', '/lib.js', [['foo', {local: 'foo', isWeak: false}]], [['foo', ['/lib1.js', 'foo']]]],
['/lib.js', '/lib1.js', [['*', {local: '*', isWeak: true}]], [['foo']]],
['/lib.js', '/lib2.js', [['*', {local: '*', isWeak: true}]], null],
],
);
});
it('dependency with * imports everything', async () => {
// prettier-ignore
await testPropagation(
[
['/index.js', [], true, []],
['/lib.js', [['a', {local: 'lib1$foo'}], ['b', {local: 'lib1$b'}]], true, ['*']],
['/lib1.js', [['b', {local: 'v'}]], true, ['b']],
['/lib2.js', [['c', {local: 'v'}]], true, ['*']],
],
[
['/index.js', '/lib.js', [['*', {local: 'lib', isWeak: false}]], [['*']]],
['/lib.js', '/lib1.js', [['b', {local: 'lib1$foo', isWeak: true}]], [['b']]],
// TODO should usedSymbolsUp actually list the individual symbols instead of '*'?
['/lib.js', '/lib2.js', [['*', {local: '*', isWeak: true}]], [['*']]],
],
);
});
it('dependency with cleared symbols imports side-effect-full parts', async () => {
// prettier-ignore
await testPropagation(
[
['/index.js', [], true, []],
['/lib.js', [['a', {local: 'lib1$foo'}], ['b', {local: 'lib$b'}]], true, ['b']],
['/lib1.js', [['b', {local: 'v'}]], true, ['b']],
['/lib2.js', [['c', {local: 'v'}]], false, ['*']],
],
[
['/index.js', '/lib.js', null, []],
['/lib.js', '/lib1.js', [['b', {local: 'lib1$foo', isWeak: true}]], [['b']]],
['/lib.js', '/lib2.js', [['*', {local: '*', isWeak: true}]], [['*']]],
],
);
});
it('dependency with cleared symbols imports side-effect-free package', async () => {
// prettier-ignore
await testPropagation(
[
['/index.js', [], true, []],
['/lib.js', [['a', {local: 'lib$a'}], ['b', {local: 'lib1$b'}], ['c', {local: 'lib2$c'}]], false, ['a']],
['/lib1.js', [['b', {local: 'v'}]], false, ['b']],
['/lib2.js', [['c', {local: 'v'}]], false, ['c']],
['/lib3.js', [['d', {local: 'v'}]], false, ['*']],
],
[
['/index.js', '/lib.js', null, []],
['/lib.js', '/lib1.js', [['b', {local: 'lib1$b', isWeak: true}]], [['b']]],
['/lib.js', '/lib2.js', [['c', {local: 'lib2$c', isWeak: true}]], [['c']]],
['/lib.js', '/lib3.js', [['*', {local: '*', isWeak: true}]], [['*']]],
],
);
});
it('library build with entry dependency', async () => {
// prettier-ignore
await testPropagation(
[
['/index.js', [["foo", {local: "foo"}], ['b', {local: 'b$b'}]], true, ['*']],
],
[],
true
);
});
it('library build with entry dependency and reexport', async () => {
// prettier-ignore
await testPropagation(
[
['/index.js', [["foo", {local: "foo"}], ['b', {local: 'b$b'}]], true, ['*']],
['/b.js', [['b', {local: 'b'}]], true, ['b']],
],
[
['/index.js', '/b.js', [['b', {local: 'b$b', isWeak: false}]], [['b']]],
],
true
);
});
it('cyclic dependency', async () => {
// prettier-ignore
await testPropagation(
[
['/index.js', [], true, []],
['/a.js', [['a', {local: 'b$b'}], ['real', {local: 'real'}]], true, ['real']],
['/b.js', [['b', {local: 'c$c'}]], true, []],
['/c.js', [['c', {local: 'a$real'}]], true, []],
],
[
['/index.js', '/a.js', [['a', {local: 'a', isWeak: false}]], [['a']]],
['/a.js', '/b.js', [['b', {local: 'b$b', isWeak: true}]], [['b']]],
['/b.js', '/c.js', [['c', {local: 'c$c', isWeak: true}]], [['c']]],
['/c.js', '/a.js', [['real', {local: 'a$real', isWeak: true}]], [['real']]],
],
);
});
});