Skip to content

Commit e0d83e3

Browse files
authored
Merge pull request #1 from hypothesis/initial-pr
Initial commit of common frontend build and test utilities
2 parents a8c4978 + e6bcbc8 commit e0d83e3

File tree

10 files changed

+1347
-0
lines changed

10 files changed

+1347
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Continuous integration
2+
on: [push]
3+
jobs:
4+
ci:
5+
runs-on: ubuntu-latest
6+
steps:
7+
- name: Checkout
8+
uses: actions/checkout@v2
9+
- name: Cache the node_modules dir
10+
uses: actions/cache@v2
11+
with:
12+
path: node_modules
13+
key: ${{ runner.os }}-node_modules-${{ hashFiles('yarn.lock') }}
14+
- name: Install
15+
run: yarn install --frozen-lockfile
16+
- name: Format
17+
run: yarn checkformatting
18+
- name: Typecheck
19+
run: yarn typecheck

.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)