blob: 575488c31d6005a9f453cb237e81b7cefe2d3bb5 [file] [log] [blame]
// TODO Specify echarts path
const fs = require('fs');
const globby = require('globby');
const {buildExampleCode, collectDeps} = require('../common/buildCode');
const nodePath = require('path');
const { runTasks } = require('../common/task');
const fse = require('fs-extra');
const prettier = require('prettier');
const ts = require('typescript');
const chalk = require('chalk');
const nStatic = require('node-static');
const webpack = require('webpack');
const {RawSource} = require('webpack-sources');
const argparse = require('argparse');
const esbuild = require('esbuild');
const puppeteer = require('puppeteer');
const config = require('./config');
const {compareImage} = require('../common/compareImage');
const shell = require('shelljs');
const downloadGit = require('download-git-repo');
const {promisify} = require('util');
const matter = require('gray-matter');
const minimatch = require('minimatch');
const parser = new argparse.ArgumentParser({
addHelp: true
});
parser.addArgument(['--bundler'], {
help: 'Bundler, can be webpack or esbuild'
});
parser.addArgument(['-m', '--minify'], {
action: 'storeTrue',
help: 'If minify'
});
parser.addArgument(['--local'], {
action: 'storeTrue',
help: `If use local repos. If so, don't forget to update the location of local repo in config.js.`
});
parser.addArgument(['--skip'], {
help: 'If skip some stages to speed up the test. Can be npm,bundle,render,compare'
});
parser.addArgument(['-t', '--tests'], {
help: 'If use pattern to specify which tests to run'
});
const args = parser.parseArgs();
const EXAMPLE_DIR = `${__dirname}/../public/`;
const TMP_DIR = `${__dirname}/tmp`;
const RUN_CODE_DIR = `${TMP_DIR}/tests`;
const BUNDLE_DIR = `${TMP_DIR}/bundles`;
const SCREENSHOTS_DIR = `${TMP_DIR}/screenshots`;
const REPO_DIR = `${TMP_DIR}/repos`;
const PACKAGE_DIR = `${TMP_DIR}/packages`;
const MINIMAL_POSTFIX = 'minimal';
const MINIMAL_LEGACY_POSTFIX = 'minimal.legacy';
const MINIFY_BUNDLE = args.minify;
// const TEST_THEME = 'dark-blue';
const TEST_THEME = '';
const USE_WEBPACK = !(args.bundler === 'esbuild');
let testsPattern = args.tests;
if (testsPattern) {
testsPattern = testsPattern.split(',');
}
// Create a server
const port = 3322;
const baseUrl = `http://localhost:${port}`;
const TEMPLATE_CODE = `
// @ts-ignore
echarts.registerPreprocessor(function (option) {
option.animation = false;
if (option.series) {
if (!(option.series instanceof Array)) {
option.series = [option.series];
}
// @ts-ignore
option.series.forEach(function (seriesOpt) {
if (seriesOpt.type === 'graph') {
seriesOpt.force = seriesOpt.force || {};
seriesOpt.force.layoutAnimation = false;
}
seriesOpt.progressive = 1e5;
seriesOpt.animation = false;
});
}
});
`
function buildPrepareCode(isESM, lang) {
return `
// @ts-ignore
${isESM
? `import _seedrandom from 'seedrandom';`
: `const _seedrandom = require('seedrandom');`
}
// Check if i18n will break the minimal imports.
${lang
? isESM
? `import 'echarts/i18n/${lang}';`
: `require('echarts/i18n/${lang}');`
: ''
}
// @ts-ignore
const _myrng = _seedrandom('echarts');
// @ts-ignore
Math.random = function () {
// @ts-ignore
return _myrng();
};
${TEMPLATE_CODE}
`;
}
async function prepare() {
fse.removeSync(TMP_DIR);
fse.removeSync(RUN_CODE_DIR);
fse.removeSync(BUNDLE_DIR);
fse.removeSync(SCREENSHOTS_DIR);
fse.removeSync(REPO_DIR);
fse.removeSync(PACKAGE_DIR);
fse.ensureDirSync(TMP_DIR);
fse.ensureDirSync(RUN_CODE_DIR);
fse.ensureDirSync(BUNDLE_DIR);
fse.ensureDirSync(SCREENSHOTS_DIR);
fse.ensureDirSync(REPO_DIR);
fse.ensureDirSync(PACKAGE_DIR);
}
async function downloadPackages(config) {
for (let pkg of config.packages) {
const pkgDownloadPath = nodePath.join(REPO_DIR, pkg.name);
console.log(chalk.gray(`Downloading ${pkg.git}`))
await promisify(downloadGit)(pkg.git, pkgDownloadPath);
// Override the path
pkg.dir = pkgDownloadPath;
}
}
async function installPackages(config) {
const publishedPackages = {};
function checkFolder(pkg) {
const dir = pkg.dir;
if (!fs.existsSync(dir)) {
console.warn(chalk.yellow(`${dir} not exists. Please update it in e2e/config.js.`));
return false;
}
if (!nodePath.isAbsolute(dir)) {
console.warn(chalk.yellow(`${dir} is not an absolute path. Please update it in e2e/config.js.`));
return false;
}
return true;
}
function publishPackage(pkg) {
console.log(chalk.gray(`Publishing ${pkg.dir}`))
shell.cd(pkg.dir);
const packageJson = JSON.parse(fs.readFileSync(nodePath.join(pkg.dir, 'package.json')));
const tgzFileName = `${packageJson.name}-${packageJson.version}.tgz`;
const targetTgzFilePath = nodePath.join(PACKAGE_DIR, tgzFileName);
if (packageJson.dependencies) {
for (let depPkgName in packageJson.dependencies) {
if (!publishedPackages[depPkgName]) {
const depPkg = config.packages.find(a => a.name === depPkgName);
if (depPkg) {
publishPackage(depPkg);
// Come back.
shell.cd(pkg.dir);
}
}
shell.exec(`npm install`);
shell.exec(`npm install ${publishedPackages[depPkgName]} --no-save`);
}
}
shell.exec(`npm pack`);
fs.renameSync(nodePath.join(pkg.dir, tgzFileName), targetTgzFilePath);
publishedPackages[packageJson.name] = targetTgzFilePath;
}
for (let pkg of config.packages) {
if (!checkFolder(pkg)) {
return;
}
publishPackage(pkg);
};
shell.cd(__dirname);
for (let pkg of config.packages) {
console.log(chalk.gray(`Installing ${pkg.name}`))
shell.exec(`npm install ${publishedPackages[pkg.name]}`);
}
// Come back.
shell.cd(process.cwd());
}
async function buildRunCode() {
const files = await globby([
`${EXAMPLE_DIR}/data/option/*.json`,
`${EXAMPLE_DIR}/data-gl/option/*.json`
]);
if (!files.length) {
throw new Error('You need to run `node tool/build-example.js` before run this test.');
}
async function addTestCase(testName, testCode, deps, checkTs, extraImports, extraRequire) {
const ROOT_PATH = `${baseUrl}/public`;
const fullCode = buildExampleCode(buildPrepareCode(true) + testCode, deps, {
minimal: false,
ts: checkTs,
// Check if theme will break the minimal imports.
theme: TEST_THEME,
ROOT_PATH,
extraImports
});
const minimalCode = buildExampleCode(buildPrepareCode(true) + testCode, deps, {
minimal: true,
ts: checkTs,
theme: TEST_THEME,
ROOT_PATH,
extraImports
});
const legacyCode = buildExampleCode(buildPrepareCode(false) + testCode, deps, {
minimal: true,
esm: false,
ts: false,
theme: TEST_THEME,
ROOT_PATH,
extraImports: extraRequire
});
await fse.writeFile(
nodePath.join(RUN_CODE_DIR, testName + (checkTs ? '.ts' : '.js')),
prettier.format(fullCode, {
singleQuote: true,
parser: checkTs ? 'typescript' : 'babel'
}), 'utf-8'
);
await fse.writeFile(
nodePath.join(RUN_CODE_DIR, testName + `.${MINIMAL_POSTFIX}.${checkTs ? 'ts' : 'js'}`),
prettier.format(minimalCode, {
singleQuote: true,
parser: checkTs ? 'typescript' : 'babel'
}), 'utf-8'
);
await fse.writeFile(
nodePath.join(RUN_CODE_DIR, testName + `.${MINIMAL_LEGACY_POSTFIX}.js`),
prettier.format(legacyCode, {
singleQuote: true,
parser: 'babel'
}), 'utf-8'
);
console.log(
chalk.green('Generated: ', testName)
);
}
const builtinTestCases = await runTasks(files, async (fileName) => {
const isGL = fileName.startsWith(`${EXAMPLE_DIR}/data-gl`);
const testName = nodePath.basename(fileName, '.json');
if (testsPattern && !testsPattern.some(pattern => minimatch(testName, pattern))) {
return;
}
const optionCode = await fse.readFile(fileName, 'utf-8');
const option = JSON.parse(optionCode);
const testCode = await fse.readFile(nodePath.join(
EXAMPLE_DIR, isGL ? 'data-gl' : 'data', testName + '.js'
), 'utf-8');
// TODO Ignore case with extension.
const deps = collectDeps(option).concat([
// TODO SVG
'CanvasRenderer'
]);
if (
!(testName === 'map-HK' || testName === 'map-usa') // Only test these two map examples
&& (deps.includes('MapChart')
|| deps.includes('GeoComponent')
|| option.bmap)
) {
console.warn(chalk.yellow(`Ignored map tests ${testName}`));
return;
}
// TODO: Don't support TypeScript in GL
await addTestCase(testName, testCode, deps, !isGL);
return testName;
}, 20);
const extensionTestCases = await runTasks(await globby(__dirname + '/cases/*.js'), async (fileName) => {
const testName = nodePath.basename(fileName, '.js');
if (testsPattern && !testsPattern.some(pattern => minimatch(testName, pattern))) {
return;
}
const testCode = await fse.readFile(fileName, 'utf-8');
let importsCode = '';
let requireCode = '';
try {
const fmResult = matter(testCode, {
delimiters: ['/*', '*/']
});
const extension = fmResult.data.extension;
if (extension) {
importsCode = `import '${extension}';`;
requireCode = `require('${extension}');`;
}
}
catch (e) {
}
await addTestCase(testName, testCode, [], false, importsCode, requireCode);
return testName;
}, 20);
return builtinTestCases.concat(extensionTestCases).filter(a => !!a);
}
async function compileTs(tsTestFiles, result) {
const config = JSON.parse(fs.readFileSync(nodePath.join(__dirname, 'tsconfig.json'), 'utf-8'));
const compilerOptions = {
...config.compilerOptions
};
const {options, errors} = ts.convertCompilerOptionsFromJson(compilerOptions, nodePath.resolve(__dirname));
if (errors.length) {
let errMsg = 'tsconfig parse failed: '
+ errors.map(error => error.messageText).join('. ')
+ '\n compilerOptions: \n' + JSON.stringify(config.compilerOptions, null, 4);
assert(false, errMsg);
}
// Generate this config file for checking the source code in vscode.
fs.writeFileSync(nodePath.join(RUN_CODE_DIR, 'tsconfig.json'), JSON.stringify({
compilerOptions
}, null, 2), 'utf-8');
// See: https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API
let program = ts.createProgram(tsTestFiles, options);
let emitResult = program.emit();
let allDiagnostics = ts
.getPreEmitDiagnostics(program)
.concat(emitResult.diagnostics);
allDiagnostics.forEach(diagnostic => {
if (diagnostic.file) {
let {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
const compilerError = {
location: [line + 1, character + 1],
message
};
if (diagnostic.file.fileName.endsWith(`${MINIMAL_POSTFIX}.ts`)) {
const basename = nodePath.basename(diagnostic.file.fileName, `.${MINIMAL_POSTFIX}.ts`);
result[basename].compileErrors.minimal.push(compilerError);
}
else {
const basename = nodePath.basename(diagnostic.file.fileName, `.ts`);
result[basename].compileErrors.full.push(compilerError);
}
// console.log(chalk.red(`${diagnostic.file.fileName} (${line + 1},${character + 1})`));
// console.log(chalk.gray(message));
}
else {
console.log(chalk.red(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')));
}
});
// assert(!emitResult.emitSkipped, 'ts compile failed.');
}
async function webpackBundle(esbuildService, entry, result) {
return new Promise((resolve) => {
webpack({
entry,
output: {
path: BUNDLE_DIR,
filename: '[name].js'
},
// Use esbuild as minify, terser is tooooooo slow for so much tests.
optimization: {
minimizer: MINIFY_BUNDLE ? [{
apply(compiler) {
compiler.hooks.compilation.tap(
'ESBuild Minify',
(compilation) => {
compilation.hooks.optimizeChunkAssets.tapPromise(
'ESBuild Minify',
async (chunks) => {
for (const chunk of chunks) {
for (const file of chunk.files) {
const asset = compilation.assets[file];
const { source } = asset.sourceAndMap();
const result = await esbuildService.transform(source, {
minify: true,
sourcemap: false
});
compilation.updateAsset(file, () => {
return new RawSource(result.code || '');
});
}
}
}
);
},
);
}
}] : []
}
}, (err, stats) => {
if (err || stats.hasErrors()) {
if (err) {
console.error(err.stack || err);
if (err.details) {
console.error(err.details);
}
resolve();
return;
}
const info = stats.toJson();
if (stats.hasErrors()) {
console.error(info.errors);
}
if (stats.hasWarnings()) {
console.warn(info.warnings);
}
}
else {
console.log(chalk.green(`${Object.values(entry).map(a => `Bundled ${a}`).join('\n')}`));
}
resolve();
});
})
}
function esbuildBundle(entry, result, minify) {
return esbuild.build({
entryPoints: entry,
bundle: true,
minify: minify,
define: {
'process.env.NODE_ENV': JSON.stringify(minify ? 'production' : 'development')
},
outdir: BUNDLE_DIR
});
}
async function bundle(entryFiles, result) {
if (USE_WEBPACK) {
// Split to multiple buckets to seepup bundle
// TODO Multiple entry may have effects on the final bundle.
const BUCKET_SIZE = 1;
const buckets = [];
const esbuildService = await esbuild.startService();
let count = 0;
outer: while (true) {
const bucket = {};
for (let i = 0; i < BUCKET_SIZE; i++) {
const filePath = entryFiles[count++];
if (!filePath) {
break outer;
}
const basename = nodePath.basename(filePath, '.js');
bucket[basename] = filePath;
}
buckets.push(bucket);
}
// TODO Multiple thread.
for (let bucket of buckets) {
await webpackBundle(esbuildService, bucket, result);
}
esbuildService.stop();
}
else {
for (let file of entryFiles) {
await esbuildBundle([file], result, MINIFY_BUNDLE);
console.log(chalk.green(`Bundled ${file}`));
}
}
}
function waitTime(time) {
return new Promise((resolve) => setTimeout(resolve, time));
}
async function runExamples(jsFiles, result) {
const fileServer = new nStatic.Server(__dirname + '/../');
const server = require('http').createServer(function (request, response) {
request.addListener('end', function () {
fileServer.serve(request, response);
}).resume();
})
server.listen(port);
try {
const IGNORE_LOG = [
'A cookie associated with a cross-site resource at',
'A parser-blocking, cross site',
// For ECharts GL
'RENDER WARNING',
'GL ERROR',
'GL_INVALID_OPERATION'
];
const browser = await puppeteer.launch({
headless: false,
args: [
'--headless',
'--hide-scrollbars',
// https://github.com/puppeteer/puppeteer/issues/4913
'--use-gl=egl',
'--mute-audio'
]
});
await runTasks(jsFiles, async (file) => {
const page = await browser.newPage();
const basename = nodePath.basename(file, '.js');
await page.setViewport({ width: 800, height: 600 });
page.on('pageerror', function (err) {
// TODO Record pageerror
console.error(chalk.red(`[PAGE ERROR] [${basename}]`));
console.error(chalk.red(err.toString()));
});
page.on('console', msg => {
const text = msg.text();
if (!IGNORE_LOG.find(a => text.indexOf(a) >= 0)) {
console.log(chalk.gray(`[PAGE LOG] [${basename}]: ${text}`));
}
});
await page.goto(`${baseUrl}/e2e/template.html`, {
waitUntil: 'networkidle0',
timeout: 10000
});
await page.addScriptTag({
url: `${baseUrl}/e2e/tmp/bundles/${basename}.js`
});
await waitTime(200);
await page.screenshot({
type: 'png',
path: nodePath.resolve(SCREENSHOTS_DIR, basename + '.png')
});
await page.close();
console.log(chalk.green(`Rendered ${file}`));
}, 8);
}
catch (e) {
server.close();
throw e;
}
}
async function compareExamples(testNames, result) {
function writePNG(png, diffPath) {
return new Promise(resolve => {
const writer = fs.createWriteStream(diffPath);
png.pack().pipe(writer);
writer.on('finish', () => { resolve(); });
});
}
for (let testName of testNames) {
const diffMinimal = await compareImage(
nodePath.resolve(SCREENSHOTS_DIR, testName + '.png'),
nodePath.resolve(SCREENSHOTS_DIR, `${testName}.${MINIMAL_POSTFIX}.png`)
);
const diffMinimalLegacy = await compareImage(
nodePath.resolve(SCREENSHOTS_DIR, testName + '.png'),
nodePath.resolve(SCREENSHOTS_DIR, `${testName}.${MINIMAL_LEGACY_POSTFIX}.png`)
);
const diffMinimalPNGPath = nodePath.resolve(SCREENSHOTS_DIR, `${testName}.${MINIMAL_POSTFIX}.diff.png`);
const diffMinimalLegacyPNGPath = nodePath.resolve(SCREENSHOTS_DIR, `${testName}.${MINIMAL_LEGACY_POSTFIX}.diff.png`);
writePNG(diffMinimal.diffPNG, diffMinimalPNGPath);
writePNG(diffMinimalLegacy.diffPNG, diffMinimalLegacyPNGPath);
result[testName].screenshotDiff = {
minimal: {
ratio: diffMinimal.diffRatio,
png: nodePath.basename(diffMinimalPNGPath)
},
minimalLegacy: {
ratio: diffMinimalLegacy.diffRatio,
png: nodePath.basename(diffMinimalLegacyPNGPath)
}
}
if (diffMinimal.diffRatio > 0 || diffMinimalLegacy.diffRatio > 0) {
console.log(chalk.red(`Failed ${testName}`));
}
else {
console.log(chalk.green(`Passed ${testName}`));
}
}
}
async function main() {
let result;
if (!args.skip && !args.tests) {
// Don't clean up if skipping some of the stages.
await prepare();
result = {};
}
else {
// Read result.
try {
result = JSON.parse(fs.readFileSync(__dirname + '/tmp/result.json', 'utf-8'));
}
catch (e) {
console.error(e);
throw 'Must run full e2e test without --skip and --tests at least once.';
}
}
function isNotSkipped(stage) {
return !((args.skip || '').indexOf(stage) >= 0);
}
// We don't have to test the npm if bundle is also skipped.
if (isNotSkipped('npm') && isNotSkipped('bundle')) {
if (!args.local) {
console.log(chalk.gray('Downloading packages'));
await downloadPackages(config);
}
console.log(chalk.gray('Installing packages'));
await installPackages(config);
}
else {
console.log(chalk.yellow('Skipped NPM.'));
}
console.log(chalk.gray('Generating codes'));
// Always build code.
const testNames = await buildRunCode();
for (let key of testNames) {
result[key] = result[key] || {};
}
Object.keys(result).forEach(key => {
// Always do TS check on all tests. So reset all compile errors.
result[key].compileErrors = {
full: [],
minimal: [],
minimalLegacy: []
};
});
console.log('Compiling TypeScript');
// Always run typescript check to generate the js code.
await compileTs(
(await globby(nodePath.join(RUN_CODE_DIR, '*.ts')))
// No need to check types of the minimal legacy imports
.filter(a => !a.endsWith(`${MINIMAL_LEGACY_POSTFIX}.ts`)),
result
);
if (isNotSkipped('bundle')) {
console.log(`Bundling with ${USE_WEBPACK ? 'webpack' : 'esbuild'}`);
const jsFiles = [];
for (let testName of testNames) {
jsFiles.push(
nodePath.join(RUN_CODE_DIR, `${testName}.js`),
nodePath.join(RUN_CODE_DIR, `${testName}.${MINIMAL_POSTFIX}.js`),
nodePath.join(RUN_CODE_DIR, `${testName}.${MINIMAL_LEGACY_POSTFIX}.js`)
);
}
await bundle(jsFiles, result);
}
else {
console.log(chalk.yellow('Skipped Bundle.'));
}
if (isNotSkipped('render')) {
console.log('Running examples');
const bundleFiles = [];
for (let testName of testNames) {
bundleFiles.push(
nodePath.join(BUNDLE_DIR, `${testName}.js`),
nodePath.join(BUNDLE_DIR, `${testName}.${MINIMAL_POSTFIX}.js`),
nodePath.join(BUNDLE_DIR, `${testName}.${MINIMAL_LEGACY_POSTFIX}.js`)
);
}
await runExamples(bundleFiles, result);
}
else {
console.log(chalk.yellow('Skipped Render.'));
}
if (isNotSkipped('compare')) {
console.log('Comparing Results');
await compareExamples(testNames, result);
}
else {
console.log(chalk.yellow('Skipped Compare.'));
}
fs.writeFileSync(__dirname + '/tmp/result.json', JSON.stringify(
result, null, 2
), 'utf-8');
}
main().catch(e => {
console.error(e);
process.exit();
}).then(() => {
process.exit();
});
process.on('SIGINT', function() {
console.log('Closing');
// Close through ctrl + c;
process.exit();
});