angular/aio/tools/examples/example-sandbox.mjs
Paul Gschwendtner c7588683d1 build: always preserve symlinks when executing e2e test commands (#49293)
Currently, examples with test commands like `ng build` are *never*
using the local version of `//packages/compiler-cli`. This is because
the CLI is invoked accidentally from within `external/aio_example_deps`.

Since the CLI relies on importing the compiler-cli, it will always
resolve the dependency from that directory- causing it to be always
the version installed via `aio/examples/tools/shared/package.json`.

We should never resolve symlinks and escape the e2e sandbox. That way
the compiler-cli would be resolved properly and could also become the
locally built one, depending on the test mode (i.e. npm or "local").

PR Close #49293
2023-03-03 19:41:35 +00:00

183 lines
No EOL
7.7 KiB
JavaScript

import jsonc from 'cjson';
import fs from 'fs-extra';
import {globbySync} from 'globby';
import path from 'node:path';
import os from 'node:os';
// Construct a sandbox environment for an example, linking in shared example node_modules
// and optionally linking in locally-built angular packages.
export async function constructExampleSandbox(examplePath, destPath, nodeModulesPath, localPackages) {
// If the sandbox folder exists delete the contents but not the folder itself. If cd'ed
// into the sandbox then bash will lose the reference and be stuck in a stray deleted folder,
// creating a confusing experience. This is relevant for developer experience with ibazel.
globbySync(`${destPath}/*`, {onlyFiles: false}).forEach(file => {
fs.rmSync(file, {
recursive: true,
force: true
});
})
fs.copySync(examplePath, destPath);
// Remove write protection as the example was copied from bazel output tree
chmodSyncRec(destPath);
// Symlink shared example node_modules, substituting for locally built deps if requested
await constructSymlinkedNodeModules(destPath, nodeModulesPath, localPackages);
// Add preserveSymlinks fixups to various files --- needed when linkin in local deps
ensurePreserveSymlinks(destPath);
}
async function constructSymlinkedNodeModules(examplePath, exampleDepsNodeModules, localPackages) {
const linkedNodeModules = path.resolve(examplePath, 'node_modules');
fs.ensureDirSync(linkedNodeModules);
await Promise.all([
linkExampleDeps(exampleDepsNodeModules, linkedNodeModules, localPackages),
linkLocalDeps(exampleDepsNodeModules, linkedNodeModules, localPackages)
]);
fs.copySync(path.join(exampleDepsNodeModules, '.bin'), path.join(linkedNodeModules, '.bin'));
pointBinSymlinksToLocalPackages(linkedNodeModules, exampleDepsNodeModules, localPackages);
}
function linkExampleDeps(exampleDepsNodeModules, linkedNodeModules, localPackages) {
const exampleDepsPackages = getPackageNamesFromNodeModules(exampleDepsNodeModules);
return Promise.all(exampleDepsPackages
.filter(pkgName => !(pkgName in localPackages))
.map(pkgName => fs.ensureSymlink(
path.join(exampleDepsNodeModules, pkgName),
path.join(linkedNodeModules, pkgName), 'dir'))
);
}
function getPackageNamesFromNodeModules(nodeModulesPath) {
return globbySync([
'@*/*',
'!@*$', // Exclude a namespace folder itself
'(?!@)*',
'!.bin',
'!.yarn-integrity',
'!_*'
], {
cwd: nodeModulesPath,
onlyDirectories: true,
dot: true
});
}
async function linkLocalDeps(exampleDepsNodeModules, linkedNodeModules, localPackages) {
const hasNpmDepForPkg = await Promise.all(Object.keys(localPackages)
.map(pkgName => fs.pathExists(path.join(exampleDepsNodeModules, pkgName))));
return Promise.all(Object.keys(localPackages).filter((pkgName, i) => hasNpmDepForPkg[i])
.map(pkgName => {
const pkgJsonPath = path.join(localPackages[pkgName], 'package.json');
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
const linkedPkgPath = path.join(linkedNodeModules, pkgName);
// If a local package has bin entries and an example invokes one, then entrypoint
// resolution escapes out of the sandboxed folder into the package folder in the output
// tree and the bin cannot resolve its deps. To prevent this, copy the full local package
// into node_modules as a way of linking it. Currently, only the `upgrade-phonecat-hybrid-2`
// which calls `ngc` seems to need this.
if (pkgJson.bin) {
fs.copySync(localPackages[pkgName], linkedPkgPath);
return;
}
// Standard case: just symlink the local package.
fs.ensureSymlinkSync(localPackages[pkgName], linkedPkgPath, 'dir')
}));
}
// The .bin folder is copied over from the original yarn_install repository, so the
// bin symlinks point there. When we link local packages in place of their npm equivalent,
// we need to alter those symlinks to point into the local package.
function pointBinSymlinksToLocalPackages(linkedNodeModules, exampleDepsNodeModules, localPackages) {
if (os.platform() === 'win32') {
// Bins on Windows are not symlinks; they are scripts that will invoke the bin
// relative to their location. The relative path will already point to the symlinked
// local package, so no further action is required.
return;
}
const allNodeModuleBins = globbySync(['**'], {
cwd: path.join(linkedNodeModules, '.bin'),
onlyFiles: true
});
allNodeModuleBins.forEach(bin => {
const symlinkTarget = fs.readlinkSync(path.join(linkedNodeModules, '.bin', bin));
for (const pkgName of Object.keys(localPackages)) {
const binMightBeInLocalPackage = symlinkTarget.includes(path.join(exampleDepsNodeModules, pkgName) + path
.sep);
if (binMightBeInLocalPackage) {
const pathToBinWithinPackage = symlinkTarget.substring(symlinkTarget.indexOf(pkgName) +
pkgName.length + path.sep.length);
const binExistsInLocalPackage = fs.existsSync(path.join(linkedNodeModules, pkgName,
pathToBinWithinPackage));
if (binExistsInLocalPackage) {
// Replace the copied bin symlink with one that points to the symlinked local package.
fs.rmSync(path.join(linkedNodeModules, '.bin', bin));
fs.ensureSymlinkSync(path.join(
'..',
pkgName,
pathToBinWithinPackage),
path.join(linkedNodeModules, '.bin', bin)
);
}
break;
}
}
});
}
/**
* When local packages are symlinked in, node will by default resolve local packages to
* their output location in the `bazel-bin`. This will then cause transitive dependencies
* to be incorrectly resolved from `bazel-bin`, instead of from within the example sandbox.
*
* Setting `preserveSymlinks` in relevant files fixes this. Note that we are intending to
* preserve symlinks in general (regardless of local packages being used), because it
* allows us to safely enable `NODE_PRESERVE_SYMLINKS=1` when executing commands inside.
*/
function ensurePreserveSymlinks(appDir) {
// Set preserveSymlinks in angular.json
const angularJsonPath = path.join(appDir, 'angular.json');
if (fs.existsSync(angularJsonPath)) {
const angularJson = jsonc.load(angularJsonPath, {encoding: 'utf-8'});
angularJson.projects['angular.io-example'].architect.build.options.preserveSymlinks = true;
angularJson.projects['angular.io-example'].architect.test.options.preserveSymlinks = true;
fs.writeFileSync(angularJsonPath, JSON.stringify(angularJson, undefined, 2));
}
// Set preserveSymlinks in any tsconfig.json files
const tsConfigPaths = globbySync([path.join(appDir, 'tsconfig*.json')]);
for (const tsConfigPath of tsConfigPaths) {
const tsConfig = jsonc.load(tsConfigPath, {encoding: 'utf-8'});
const isRootConfig = !tsConfig.extends;
if (isRootConfig) {
tsConfig.compilerOptions.preserveSymlinks = true;
fs.writeFileSync(tsConfigPath, JSON.stringify(tsConfig, undefined, 2));
}
}
// Call rollup with --preserveSymlinks
const packageJsonPath = path.join(appDir, 'package.json');
const packageJson = jsonc.load(packageJsonPath, {encoding: 'utf-8'});
if ('rollup' in packageJson.dependencies || 'rollup' in packageJson.devDependencies) {
packageJson.scripts.rollup = 'rollup --preserveSymlinks';
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, undefined, 2));
}
}
function chmodSyncRec(dest) {
// Glob patterns always use unix-style paths
const allFilesPattern = path.join(dest, '**').replace(/\\/g, '/');
globbySync(allFilesPattern, {
dot: true,
onlyFiles: false
}).forEach(file => fs.chmodSync(file, '755'));
fs.chmodSync(dest, '755');
}