Skip to content

Commit 78578bb

Browse files
committed
Initial commit of common frontend build and test utilities
Commit functions for building JavaScript and CSS bundles, and running tests. These were extracted from the frontend-shared Hypothesis project, converted to ES modules and had type annotations added. This functionality includes: - `buildJS` and `watchJS` functions for building JS bundles from Rollup config files - `compileSass` for building CSS bundles from SASS input - `buildAndRunTests` for building a JS bundle of tests from a Rollup config file and running them in Karma - `run` for running CLI programs from Gulp tasks
1 parent a8c4978 commit 78578bb

File tree

9 files changed

+1328
-0
lines changed

9 files changed

+1328
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/

index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { buildJS, watchJS } from './lib/rollup.js';
2+
export { buildCSS } from './lib/sass.js';
3+
export { runTests } from './lib/tests.js';
4+
export { run } from './lib/run.js';

lib/rollup.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { resolve } from 'path';
2+
3+
import log from 'fancy-log';
4+
import * as rollup from 'rollup';
5+
6+
/** @param {import('rollup').RollupWarning} warning */
7+
function logRollupWarning(warning) {
8+
log.info(`Rollup warning: ${warning} (${warning.url})`);
9+
}
10+
11+
/** @param {string} path */
12+
async function readConfig(path) {
13+
const { default: config } = await import(resolve(path));
14+
return Array.isArray(config) ? config : [config];
15+
}
16+
17+
/**
18+
* Build a JavaScript bundle using a Rollup config.
19+
*
20+
* @param {string} rollupConfig - Path to Rollup config file
21+
*/
22+
export async function buildJS(rollupConfig) {
23+
const configs = await readConfig(rollupConfig);
24+
25+
await Promise.all(
26+
configs.map(async config => {
27+
const bundle = await rollup.rollup({
28+
...config,
29+
onwarn: logRollupWarning,
30+
});
31+
await bundle.write(config.output);
32+
})
33+
);
34+
}
35+
36+
/**
37+
* Build a JavaScript bundle using a Rollup config and auto-rebuild when any
38+
* source files change.
39+
*
40+
* @param {string} rollupConfig - Path to Rollup config file
41+
* @return {Promise<void>}
42+
*/
43+
export async function watchJS(rollupConfig) {
44+
const configs = await readConfig(rollupConfig);
45+
46+
const watcher = rollup.watch(
47+
configs.map(config => ({
48+
...config,
49+
onwarn: logRollupWarning,
50+
}))
51+
);
52+
53+
return new Promise(resolve => {
54+
watcher.on('event', event => {
55+
switch (event.code) {
56+
case 'START':
57+
log.info('JS build starting...');
58+
break;
59+
case 'BUNDLE_END':
60+
event.result.close();
61+
break;
62+
case 'ERROR':
63+
log.info('JS build error', event.error);
64+
break;
65+
case 'END':
66+
log.info('JS build completed.');
67+
resolve(); // Resolve once the initial build completes.
68+
break;
69+
}
70+
});
71+
});
72+
}

lib/run.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { spawn } from 'child_process';
2+
3+
/**
4+
* Run a command and return a promise for when it completes.
5+
*
6+
* Output and environment is forwarded as if running a CLI command in the terminal
7+
* or make.
8+
*
9+
* This function is useful for running CLI tools as part of a gulp command.
10+
*
11+
* @param {string} cmd - Command to run
12+
* @param {string[]} args - Command arguments
13+
* @param {object} options - Options to forward to `spawn`
14+
* @return {Promise<string>}
15+
*/
16+
export function run(cmd, args, options = {}) {
17+
return new Promise((resolve, reject) => {
18+
/** @type {string[]} */
19+
const stdout = [];
20+
/** @type {string[]} */
21+
const stderr = [];
22+
const cp = spawn(cmd, args, { env: process.env, ...options });
23+
cp.on('exit', code => {
24+
if (code === 0) {
25+
resolve(stdout.join(''));
26+
} else {
27+
reject(
28+
new Error(`${cmd} exited with status ${code}. \n${stderr.join('')}`)
29+
);
30+
}
31+
});
32+
cp.stdout.on('data', data => {
33+
stdout.push(data.toString());
34+
});
35+
cp.stderr.on('data', data => {
36+
stderr.push(data.toString());
37+
});
38+
});
39+
}

lib/sass.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { mkdir, writeFile } from 'fs/promises';
2+
import { basename, dirname, extname } from 'path';
3+
4+
import autoprefixer from 'autoprefixer';
5+
import postcss from 'postcss';
6+
import sass from 'sass';
7+
8+
/**
9+
* Build CSS bundles from SASS or CSS inputs.
10+
*
11+
* @param {string[]} inputs - An array of CSS or SCSS file paths specifying the
12+
* entry points of style bundles. The output files will be written to
13+
* `build/styles/[name].css` where `[name]` is the basename of the input file
14+
* minus the file extension.
15+
* @return {Promise<void>} Promise for completion of the build.
16+
*/
17+
export async function buildCSS(inputs) {
18+
const outDir = 'build/styles';
19+
const minify = process.env.NODE_ENV === 'production';
20+
await mkdir(outDir, { recursive: true });
21+
22+
await Promise.all(
23+
inputs.map(async input => {
24+
const output = `${outDir}/${basename(input, extname(input))}.css`;
25+
const sourcemapPath = output + '.map';
26+
27+
const sassResult = sass.renderSync({
28+
file: input,
29+
includePaths: [dirname(input), 'node_modules'],
30+
outputStyle: minify ? 'compressed' : 'expanded',
31+
sourceMap: sourcemapPath,
32+
});
33+
34+
const cssProcessor = postcss([autoprefixer()]);
35+
const postcssResult = await cssProcessor.process(sassResult.css, {
36+
from: output,
37+
to: output,
38+
map: {
39+
inline: false,
40+
prev: sassResult.map?.toString(),
41+
},
42+
});
43+
44+
await writeFile(output, postcssResult.css);
45+
await writeFile(sourcemapPath, postcssResult.map.toString());
46+
})
47+
);
48+
}

lib/tests.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { mkdirSync, writeFileSync } from 'fs';
2+
import * as path from 'path';
3+
4+
import { program } from 'commander';
5+
import glob from 'glob';
6+
import log from 'fancy-log';
7+
8+
import { buildJS, watchJS } from './rollup.js';
9+
10+
/**
11+
* Build a bundle of tests and run them using Karma.
12+
*
13+
* @param {object} options
14+
* @param {string} options.bootstrapFile - Entry point for the test bundle that initializes the environment
15+
* @param {string} options.rollupConfig - Rollup config that generates the test bundle using
16+
* `${outputDir}/test-inputs.js` as an entry point
17+
* @param {string} options.karmaConfig - Karma config file
18+
* @param {string} options.outputDir - Directory in which to generate test bundle. Defaults to
19+
* `build/scripts`
20+
* @param {string} options.testsPattern - Minimatch pattern that specifies which test files to
21+
* load
22+
* @return {Promise<void>} - Promise that resolves when test run completes
23+
*/
24+
export async function runTests({
25+
bootstrapFile,
26+
rollupConfig,
27+
outputDir = 'build/scripts',
28+
karmaConfig,
29+
testsPattern,
30+
}) {
31+
// Parse command-line options for test execution.
32+
program
33+
.option(
34+
'--grep <pattern>',
35+
'Run only tests where filename matches a regex pattern'
36+
)
37+
.option('--watch', 'Continuously run tests (default: false)', false)
38+
.parse(process.argv);
39+
40+
const { grep, watch } = program.opts();
41+
const singleRun = !watch;
42+
43+
// Generate an entry file for the test bundle. This imports all the test
44+
// modules, filtered by the pattern specified by the `--grep` CLI option.
45+
const testFiles = [
46+
bootstrapFile,
47+
...glob.sync(testsPattern).filter(path => (grep ? path.match(grep) : true)),
48+
];
49+
50+
const testSource = testFiles
51+
.map(path => `import "../../${path}";`)
52+
.join('\n');
53+
54+
mkdirSync(outputDir, { recursive: true });
55+
writeFileSync(`${outputDir}/test-inputs.js`, testSource);
56+
57+
// Build the test bundle.
58+
log(`Building test bundle... (${testFiles.length} files)`);
59+
if (singleRun) {
60+
await buildJS(rollupConfig);
61+
} else {
62+
await watchJS(rollupConfig);
63+
}
64+
65+
// Run the tests.
66+
log('Starting Karma...');
67+
const { default: karma } = await import('karma');
68+
const parsedConfig = await karma.config.parseConfig(
69+
path.resolve(karmaConfig),
70+
{ singleRun }
71+
);
72+
73+
return new Promise((resolve, reject) => {
74+
new karma.Server(parsedConfig, exitCode => {
75+
if (exitCode === 0) {
76+
resolve();
77+
} else {
78+
reject(new Error(`Karma run failed with status ${exitCode}`));
79+
}
80+
}).start();
81+
82+
process.on('SIGINT', () => {
83+
// Give Karma a chance to handle SIGINT and cleanup, but forcibly
84+
// exit if it takes too long.
85+
setTimeout(() => {
86+
resolve();
87+
process.exit(1);
88+
}, 5000);
89+
});
90+
});
91+
}

package.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "@hypothesis/frontend-build",
3+
"version": "1.0.0",
4+
"description": "Hypothesis frontend build scripts",
5+
"type": "module",
6+
"exports": "./index.js",
7+
"repository": "https://github.com/hypothesis/frontend-build",
8+
"author": "Hypothesis developers",
9+
"license": "BSD-2-Clause",
10+
"private": false,
11+
"files": [
12+
"index.js",
13+
"lib/*.js"
14+
],
15+
"devDependencies": {
16+
"@types/fancy-log": "^1.3.1",
17+
"@types/glob": "^7.1.4",
18+
"@types/karma": "^6.3.1",
19+
"@types/node": "^16.11.1",
20+
"@types/sass": "^1.16.1",
21+
"autoprefixer": "^10.3.7",
22+
"karma": "^6.3.4",
23+
"postcss": "^8.3.9",
24+
"prettier": "^2.4.1",
25+
"rollup": "^2.58.0",
26+
"sass": "^1.43.2",
27+
"typescript": "^4.4.4"
28+
},
29+
"dependencies": {
30+
"commander": "^8.2.0",
31+
"fancy-log": "^1.3.3",
32+
"glob": "^7.2.0"
33+
},
34+
"peerDependencies": {
35+
"autoprefixer": "^10.3.7",
36+
"karma": "^6.3.4",
37+
"postcss": "^8.3.9",
38+
"rollup": "^2.58.0",
39+
"sass": "^1.43.2"
40+
},
41+
"prettier": {
42+
"arrowParens": "avoid",
43+
"singleQuote": true
44+
},
45+
"scripts": {
46+
"checkformatting": "prettier --check '**/*.js'",
47+
"format": "prettier --list-different --write '**/*.js'",
48+
"typecheck": "tsc"
49+
}
50+
}

tsconfig.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"compilerOptions": {
3+
/* Visit https://aka.ms/tsconfig.json to read more about this file */
4+
5+
/* Projects */
6+
7+
/* Language and Environment */
8+
"target": "es2020",
9+
"moduleResolution": "node",
10+
"module": "es2020",
11+
"allowSyntheticDefaultImports": true,
12+
13+
/* JavaScript Support */
14+
"allowJs": true,
15+
"checkJs": true,
16+
17+
"noEmit": true,
18+
19+
/* Interop Constraints */
20+
"forceConsistentCasingInFileNames": true,
21+
22+
/* Type Checking */
23+
"strict": true,
24+
25+
/* Completeness */
26+
"skipLibCheck": true
27+
}
28+
}

0 commit comments

Comments
 (0)