|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +import {ConsoleLogger, LogLevel, NodeJSFileSystem} from '@angular/compiler-cli'; |
| 4 | +import {createEs2015LinkerPlugin} from '@angular/compiler-cli/linker/babel'; |
| 5 | +import {transformAsync} from '@babel/core'; |
| 6 | +import child_process from 'child_process'; |
| 7 | +import esbuild from 'esbuild'; |
| 8 | +import fs from 'fs'; |
| 9 | +import glob from 'glob'; |
| 10 | +import {dirname, join, relative} from 'path'; |
| 11 | +import sass from 'sass'; |
| 12 | +import url from 'url'; |
| 13 | + |
| 14 | +const containingDir = dirname(url.fileURLToPath(import.meta.url)); |
| 15 | +const projectDir = join(containingDir, '../'); |
| 16 | +const legacyTsconfigPath = join(projectDir, 'src/tsconfig-legacy.json'); |
| 17 | + |
| 18 | +const distDir = join(projectDir, 'dist/'); |
| 19 | +const nodeModulesDir = join(projectDir, 'node_modules/'); |
| 20 | +const outFile = join(distDir, 'legacy-test-bundle.spec.js'); |
| 21 | +const ngcBinFile = join(nodeModulesDir, '@angular/compiler-cli/bundles/src/bin/ngc.js'); |
| 22 | +const legacyOutputDir = join(distDir, 'legacy-test-out'); |
| 23 | + |
| 24 | + |
| 25 | +/** |
| 26 | + * This script builds the whole library in `angular/components` together with its |
| 27 | + * spec files into a single IIFE bundle. |
| 28 | + * |
| 29 | + * The bundle can then be used in the legacy Saucelabs or Browserstack tests. Bundling |
| 30 | + * helps with running the Angular linker on framework packages, and also avoids unnecessary |
| 31 | + * complexity with maintaining module resolution at runtime through e.g. SystemJS. |
| 32 | + */ |
| 33 | +async function main() { |
| 34 | + // Wait for all Sass compilations to finish. |
| 35 | + await compileSassFiles(); |
| 36 | + |
| 37 | + // Build the project with Ngtsc so that external resources are inlined. |
| 38 | + await compileProjectWithNgtsc(); |
| 39 | + |
| 40 | + const specEntryPointFile = await createEntryPointSpecFile(); |
| 41 | + const esbuildLinkerPlugin = await createLinkerEsbuildPlugin(); |
| 42 | + const esbuildResolvePlugin = await createResolveEsbuildPlugin(); |
| 43 | + |
| 44 | + const result = await esbuild.build({ |
| 45 | + bundle: true, |
| 46 | + sourceRoot: projectDir, |
| 47 | + platform: 'browser', |
| 48 | + format: 'iife', |
| 49 | + outfile: outFile, |
| 50 | + plugins: [esbuildResolvePlugin, esbuildLinkerPlugin], |
| 51 | + stdin: {contents: specEntryPointFile, resolveDir: projectDir}, |
| 52 | + }); |
| 53 | + |
| 54 | + if (result.errors.length) { |
| 55 | + throw Error('Could not build legacy test bundle. See errors above.'); |
| 56 | + } |
| 57 | +} |
| 58 | + |
| 59 | +/** |
| 60 | + * Compiles all non-partial Sass files in the project and writes them next |
| 61 | + * to their source files. The files are written into the source root as |
| 62 | + * this simplifies the resolution within the standalone Angular compiler. |
| 63 | + * |
| 64 | + * Given that the legacy tests should only run on CI, it is acceptable to |
| 65 | + * write to the checked-in source tree. The files remain untracked unless |
| 66 | + * explicitly added. |
| 67 | + */ |
| 68 | +async function compileSassFiles() { |
| 69 | + const sassFiles = glob.sync('src/**/!(_*|theme).scss', {cwd: projectDir, absolute: true}); |
| 70 | + const sassTasks = []; |
| 71 | + |
| 72 | + for (const file of sassFiles) { |
| 73 | + const outRelativePath = relative(projectDir, file).replace(/\.scss$/, '.css'); |
| 74 | + const outPath = join(projectDir, outRelativePath); |
| 75 | + const task = renderSassFileAsync(file).then(async (content) => { |
| 76 | + console.info('Compiled, now writing:', outRelativePath); |
| 77 | + await fs.promises.mkdir(dirname(outPath), {recursive: true}); |
| 78 | + await fs.promises.writeFile(outPath, content) |
| 79 | + }); |
| 80 | + |
| 81 | + sassTasks.push(task); |
| 82 | + } |
| 83 | + |
| 84 | + // Wait for all Sass compilations to finish. |
| 85 | + await Promise.all(sassTasks); |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * Compiles the project using the Angular compiler in order to produce JS output of |
| 90 | + * the packages and tests. This step is important in order to full-compile all |
| 91 | + * exported components of the library (inlining external stylesheets or templates). |
| 92 | + */ |
| 93 | +async function compileProjectWithNgtsc() { |
| 94 | + // Build the project with Ngtsc so that external resources are inlined. |
| 95 | + const ngcProcess = child_process.spawnSync( |
| 96 | + 'node', [ngcBinFile, '--project', legacyTsconfigPath], {shell: true, stdio: 'inherit'}); |
| 97 | + |
| 98 | + if (ngcProcess.error || ngcProcess.status !== 0) { |
| 99 | + throw Error('Unable to compile tests and library. See error above.'); |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * Queries for all spec files in the built output and creates a single |
| 105 | + * ESM entry-point file which imports all the spec files. |
| 106 | + * |
| 107 | + * This spec file can then be used as entry-point for ESBuild in order |
| 108 | + * to bundle all specs in an IIFE file. |
| 109 | + */ |
| 110 | +async function createEntryPointSpecFile() { |
| 111 | + const testFiles = glob.sync('**/*.spec.js', {absolute: true, cwd: legacyOutputDir}); |
| 112 | + |
| 113 | + let specEntryPointFile = `import './test/angular-test-init-spec.ts';`; |
| 114 | + let i = 0; |
| 115 | + const testNamespaces = []; |
| 116 | + |
| 117 | + for (const file of testFiles) { |
| 118 | + const relativePath = relative(projectDir, file); |
| 119 | + const specifier = `./${relativePath.replace(/\\/g, '/')}`; |
| 120 | + const testNamespace = `__${i++}`; |
| 121 | + |
| 122 | + testNamespaces.push(testNamespace); |
| 123 | + specEntryPointFile += `import * as ${testNamespace} from '${specifier}';\n`; |
| 124 | + } |
| 125 | + |
| 126 | + for (const namespaceId of testNamespaces) { |
| 127 | + // We generate a side-effect invocation that references the test import. This |
| 128 | + // is necessary to trick `ESBuild` in preserving the imports. Unfortunately the |
| 129 | + // test files would be dead-code eliminated otherwise because the specs are part |
| 130 | + // of folders with `package.json` files setting the `"sideEffects: false"` field. |
| 131 | + specEntryPointFile += `new Function('x', 'return x')(${namespaceId});\n`; |
| 132 | + } |
| 133 | + |
| 134 | + return specEntryPointFile; |
| 135 | +} |
| 136 | + |
| 137 | +/** Helper function to render a Sass file asynchronously using promises. */ |
| 138 | +async function renderSassFileAsync(inputFile) { |
| 139 | + return new Promise((resolve, reject) => { |
| 140 | + sass.render( |
| 141 | + {file: inputFile, includePaths: [nodeModulesDir]}, |
| 142 | + (err, result) => err ? reject(err) : resolve(result.css)); |
| 143 | + }); |
| 144 | +} |
| 145 | + |
| 146 | +/** |
| 147 | + * Creates an ESBuild plugin which maps `@angular/<..>` module names to their |
| 148 | + * locally-built output (for the packages which are built as part of this repo). |
| 149 | + */ |
| 150 | +async function createResolveEsbuildPlugin() { |
| 151 | + return { |
| 152 | + name: 'ng-resolve-esbuild', setup: (build) => { |
| 153 | + build.onResolve({filter: /@angular\//}, async (args) => { |
| 154 | + const pkgName = args.path.substr('@angular/'.length); |
| 155 | + let resolvedPath = join(legacyOutputDir, pkgName) |
| 156 | + let stats = await statGraceful(resolvedPath); |
| 157 | + |
| 158 | + // If the resolved path points to a directory, resolve the contained `index.js` file |
| 159 | + if (stats && stats.isDirectory()) { |
| 160 | + resolvedPath = join(resolvedPath, 'index.js'); |
| 161 | + stats = await statGraceful(resolvedPath); |
| 162 | + } |
| 163 | + // If the resolved path does not exist, check with an explicit JS extension. |
| 164 | + else if (stats === null) { |
| 165 | + resolvedPath += '.js'; |
| 166 | + stats = await statGraceful(resolvedPath); |
| 167 | + } |
| 168 | + |
| 169 | + return stats !== null ? {path: resolvedPath} : undefined; |
| 170 | + }); |
| 171 | + } |
| 172 | + } |
| 173 | +} |
| 174 | + |
| 175 | +/** Creates an ESBuild plugin that runs the Angular linker on framework packages. */ |
| 176 | +async function createLinkerEsbuildPlugin() { |
| 177 | + const linkerBabelPlugin = createEs2015LinkerPlugin({ |
| 178 | + fileSystem: new NodeJSFileSystem(), |
| 179 | + logger: new ConsoleLogger(LogLevel.warn), |
| 180 | + // We enable JIT mode as unit tests also will rely on the linked ESM files. |
| 181 | + linkerJitMode: true, |
| 182 | + }); |
| 183 | + |
| 184 | + return { |
| 185 | + name: 'ng-linker-esbuild', |
| 186 | + setup: (build) => { |
| 187 | + build.onLoad({filter: /fesm2020/}, async (args) => { |
| 188 | + const filePath = args.path; |
| 189 | + const content = await fs.promises.readFile(filePath, 'utf8'); |
| 190 | + const {code} = await transformAsync(content, { |
| 191 | + filename: filePath, |
| 192 | + filenameRelative: filePath, |
| 193 | + plugins: [linkerBabelPlugin], |
| 194 | + sourceMaps: 'inline', |
| 195 | + }); |
| 196 | + return {contents: code}; |
| 197 | + }); |
| 198 | + }, |
| 199 | + }; |
| 200 | +} |
| 201 | + |
| 202 | +/** |
| 203 | + * Retrieves the `fs.Stats` results for the given path gracefully. |
| 204 | + * If the file does not exist, returns `null`. |
| 205 | + */ |
| 206 | +async function statGraceful(path) { |
| 207 | + try { |
| 208 | + return await fs.promises.stat(path); |
| 209 | + } catch { |
| 210 | + return null; |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +main().catch(e => { |
| 215 | + console.error(e); |
| 216 | + process.exitCode = 1; |
| 217 | +}); |
0 commit comments