diff --git a/package.json b/package.json index 699184cf2cd..6d71537ae53 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "typescript-eslint": "^8.66.0" }, "scripts": { - "test": "npx nx run-many -t test --exclude=blockly && npx nx run blockly:test", + "test": "npx nx run-many -t test", "lint": "npm run lint --ws --if-present", "lint-fix": "npm run lint-fix --ws --if-present", "build": "npx nx run-many -t build --exclude=blockly-docs", diff --git a/packages/blockly/gulpfile.mjs b/packages/blockly/gulpfile.mjs index 82b393382ef..d68143d2d7c 100644 --- a/packages/blockly/gulpfile.mjs +++ b/packages/blockly/gulpfile.mjs @@ -36,7 +36,6 @@ import { pack, typings, } from './scripts/gulpfiles/package_tasks.mjs'; -import {generators, test} from './scripts/gulpfiles/test_tasks.mjs'; const clean = parallel(cleanBuildDir, cleanReleaseDir); @@ -66,8 +65,6 @@ export { export { messages, // Generate msg/json/en.json et al. clean, - test, - generators as testGenerators, buildAdvancedCompilationTest, typings, } diff --git a/packages/blockly/package.json b/packages/blockly/package.json index ef69695c10b..df156c62aa8 100644 --- a/packages/blockly/package.json +++ b/packages/blockly/package.json @@ -51,6 +51,10 @@ }, "test": { "dependsOn": [ + { + "projects": "blockly", + "target": "package" + }, { "projects": "@blockly/block-test", "target": "build" @@ -61,6 +65,14 @@ } ] }, + "test-compile-advanced-browser": { + "dependsOn": [ + { + "projects": "blockly", + "target": "test-compile-advanced" + } + ] + }, "docs": { "dependsOn": [ "package", @@ -101,13 +113,16 @@ "rebuildAdvancedPlayground": "npm run build --workspace=@blockly/dev-tools && node \"scripts/prepare_advanced_playground.mjs\"", "rebuildBlockly": "npm run build && node \"scripts/prepare_advanced_playground.mjs\"", "tsc": "gulp tsc", - "test": "gulp test", + "test": "nx run-many -p blockly --targets=lint,test-renamings,test-mocha-node,test-generators,test-type-definitions,test-compile-advanced,test-compile-advanced-browser", "test-browser": "npx mocha --config tests/browser/.mocharc.js", "test-mocha-node": "npm run test-mocha-typecheck && npx mocha --config tests/mocha/.mocharc.node.cjs \"tests/mocha/**/*_test.{js,ts}\"", "test-mocha-typecheck": "tsc -p tests/mocha/tsconfig.json", - "test-generators": "gulp testGenerators", + "test-generators": "node \"tests/generators/test_generators.mjs\"", "test-mocha-interactive": "npm run build && node scripts/prepare_mocha_bundle.mjs && concurrently -n tsc,tsc:tests,esbuild,python3 \"tsc --watch --preserveWatchOutput --outDir \"build/src\" --declarationDir \"build/declarations\"\" \"npm run test-mocha-typecheck -- --watch --preserveWatchOutput\" \"esbuild build/tests/bundle-entry.js --bundle --sourcemap --alias:blockly/core=./build/src/core/blockly.js --alias:blockly/blocks=./build/src/blocks/blocks.js --alias:blockly=./build/src/core/blockly.js --outfile=build/tests/mocha-bundle.js --servedir=. --serve=127.0.0.1:8080 --watch=forever --log-level=warning\" \"python3 -m webbrowser 'http://localhost:8080/tests/mocha/index.html'\"", "test-compile-advanced": "gulp buildAdvancedCompilationTest --debug", + "test-compile-advanced-browser": "node \"tests/compile/webdriver.js\"", + "test-renamings": "node \"tests/migration/validate-renamings.mjs\"", + "test-type-definitions": "tsc -p ./tests/typescript/tsconfig.json -outDir \"build/src\"", "updateGithubPages": "node scripts/update_github_pages.mjs --upstream", "updateGithubPages-staging": "node scripts/update_github_pages.mjs --use-local" }, diff --git a/packages/blockly/scripts/gulpfiles/test_tasks.mjs b/packages/blockly/scripts/gulpfiles/test_tasks.mjs deleted file mode 100644 index 031f43482c0..00000000000 --- a/packages/blockly/scripts/gulpfiles/test_tasks.mjs +++ /dev/null @@ -1,305 +0,0 @@ -/** - * @license - * Copyright 2022 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Gulp tasks to test. - */ -/* eslint-env node */ - -import asyncDone from 'async-done'; -import {spawnSync} from 'child_process'; -import * as fs from 'fs'; -import * as gulp from 'gulp'; -import * as path from 'path'; -import {rimraf} from 'rimraf'; - -import {TEST_TSC_OUTPUT_DIR} from './config.mjs'; - -const OUTPUT_DIR = 'build/generators'; -const GOLDEN_DIR = 'tests/generators/golden'; - -const BOLD_GREEN = '\x1b[1;32m'; -const BOLD_RED = '\x1b[1;31m'; -const ANSI_RESET = '\x1b[0m'; - -let successCount = 0; -let failCount = 0; -let firstErr; -const results = {}; - -/** - * Run an arbitrary Gulp task as a test. - * @param {function} task Any Gulp task. - * @return {Promise} Asynchronous result. - */ -function runTestTask(id, task) { - return new Promise((resolve) => { - console.log('======================================='); - console.log(`== ${id}`); - - // Turn any task into a Promise! - const asyncTask = new Promise((resolve, reject) => { - asyncDone(task, (error, result) => { - if (error) reject(error); - resolve(result); - }); - }); - - if (process.env.CI) console.log('::group::'); - asyncTask - .then((result) => { - successCount++; - if (process.env.CI) console.log('::endgroup::'); - console.log(`${BOLD_GREEN}SUCCESS:${ANSI_RESET} ${id}`); - results[id] = {success: true}; - resolve(result); - }) - .catch((err) => { - failCount++; - if (!firstErr) { - // Save the first error so we can use it in the stack trace later. - firstErr = err; - } - console.error(err.message); - if (process.env.CI) console.log('::endgroup::'); - console.log(`${BOLD_RED}FAILED:${ANSI_RESET} ${id}`); - results[id] = {success: false, message: err.message}; - // Always continue. - resolve(err); - }); - }); -} - -function createSummary() { - let summary = '# Test Summary\n\n'; - summary += '|Test Name|Passed?|Error message|\n'; - summary += '|---------|-------|-------------|\n'; - for (const test in results) { - summary += `|${test}|${results[test].success - ? ':white_check_mark:' : ':x:'}|${results[test].message ?? ''}|\n`; - } - summary += `\n\n## Total: ${successCount} passed. ${failCount} failed.`; - return summary; -} - -/** - * Print test results and fail the task if needed. - */ -function reportTestResult() { - console.log('======================================='); - if (process.env.CI && process.env.GITHUB_STEP_SUMMARY) { - try { - fs.writeFileSync(process.env.GITHUB_STEP_SUMMARY, createSummary()); - } catch(e) { - // Don't fail CI just because we couldn't write the summary. - console.log('Failed to write job summary', e); - } - } - // Check result. - if (failCount === 0) { - console.log( - `${BOLD_GREEN}All ${successCount} tests passed.${ANSI_RESET}`); - return Promise.resolve(); - } - console.log( - `${BOLD_RED}Failures in ${failCount} test groups.${ANSI_RESET}`); - return Promise.reject(firstErr || - 'Unspecified test failures, see above. The following stack trace is unlikely to be useful.'); -} - -/** - * Helper method for running test command. - * @param {string} command Command line to run. - * @return {Promise} Asynchronous result. - */ -async function runTestCommand(id, command) { - return runTestTask(id, async () => { - const result = spawnSync(command, { - shell: true, - stdio: 'inherit', - env: process.env, - }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error( - `Command failed with exit code ${result.status}: ${command}`, - ); - } - }); -} - -/** - * Lint the codebase. - * Skip for CI environments, because linting is run separately. - * @return {Promise} Asynchronous result. - */ -function eslint() { - if (process.env.CI) { - console.log('Skip linting.'); - return Promise.resolve(); - } - return runTestCommand('eslint', 'eslint .'); -} - -/** - * Run the full usual build and package process, checking to ensure - * there are no Closure Compiler warnings / errors. - * @return {Promise} Asynchronous result. - */ -function build() { - return runTestCommand('build', 'npm run package -- --verbose --debug'); -} - -/** - * Run renaming validation test. - * @return {Promise} Asynchronous result. - */ -function renamings() { - return runTestCommand('renamings', 'tests/migration/validate-renamings.mjs'); -} - -/** - * Run Mocha tests under Node. - * @return {Promise} Asynchronous result. - */ -function mocha() { - return runTestCommand('mocha', 'npm run test-mocha-node'); -} - -/** - * Helper method for comparison file. - * @param {string} file1 First target file. - * @param {string} file2 Second target file. - * @return {boolean} Comparison result (true: same / false: different). - */ -function compareFile(file1, file2) { - const buf1 = fs.readFileSync(file1); - const buf2 = fs.readFileSync(file2); - // Normalize the line feed. - const code1 = buf1.toString().replace(/(?:\r\n|\r|\n)/g, '\n'); - const code2 = buf2.toString().replace(/(?:\r\n|\r|\n)/g, '\n'); - return code1 === code2; -} - -/** - * Helper method for checking the result of generator. - * @param {string} suffix Target suffix. - * @return {number} Check result (0: success / 1: failed). - */ -function checkResult(suffix) { - const fileName = `generated.${suffix}`; - const resultFileName = path.posix.join(OUTPUT_DIR, fileName); - - const SUCCESS_PREFIX = `${BOLD_GREEN}SUCCESS:${ANSI_RESET}`; - const FAILURE_PREFIX = `${BOLD_RED}FAILED:${ANSI_RESET}`; - - if (fs.existsSync(resultFileName)) { - const goldenFileName = path.posix.join(GOLDEN_DIR, fileName); - if (fs.existsSync(goldenFileName)) { - if (compareFile(resultFileName, goldenFileName)) { - console.log(`${SUCCESS_PREFIX} ${suffix}: ` + - `${resultFileName} matches ${goldenFileName}`); - return 0; - } else { - console.log( - `${FAILURE_PREFIX} ${suffix}: ` + - `${resultFileName} does not match ${goldenFileName}`); - } - } else { - console.log(`File ${goldenFileName} not found!`); - } - } else { - console.log(`File ${resultFileName} not found!`); - } - return 1; -} - -/** - * Run generator tests inside a browser and check the results. - * @return {Promise} Asynchronous result. - */ -export async function generators() { - return runTestTask('generators', async () => { - // Clean up. - rimraf.sync(OUTPUT_DIR); - fs.mkdirSync(OUTPUT_DIR); - - const result = spawnSync('node', ['tests/generators/webdriver.js', OUTPUT_DIR], { - stdio: 'inherit', - env: process.env, - }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error('Generator browser tests failed.'); - } - - const generatorSuffixes = ['js', 'py', 'dart', 'lua', 'php']; - let failed = 0; - generatorSuffixes.forEach((suffix) => { - failed += checkResult(suffix); - }); - - if (failed === 0) { - console.log(`${BOLD_GREEN}All generator tests passed.${ANSI_RESET}`); - } else { - console.log( - `${BOLD_RED}Failures in ${failed} generator tests.${ANSI_RESET}`); - throw new Error('Generator tests failed.'); - } - }); -} - -/** - * Attempt advanced compilation of a Blockly app. - * @returns {Promise} Async result. - */ -function advancedCompile() { - return runTestCommand('advanced_compile', 'npm run test-compile-advanced'); -} - -/** - * Attempt advanced compilation of a Blockly app and make sure it runs in the browser. - * Should be run after the `advancedCompile` test. - * @return {Promise} Asynchronous result. - */ -function advancedCompileInBrowser() { - return runTestCommand( - 'advanced_compile_in_browser', - 'node tests/compile/webdriver.js', - ); -} - -/** - * Verify the built Blockly type definitions compile with the supported - * TypeScript examples included in `./tests/typescript`. - * @returns {Promise} Asynchronous result. - */ -function typeDefinitions() { - return runTestCommand('type_definitions', - `tsc -p ./tests/typescript/tsconfig.json -outDir ${TEST_TSC_OUTPUT_DIR}`); -} - -// Run all tests in sequence. -const tasks = [ - eslint, - // Build must run before the remaining tasks - build, - renamings, - mocha, - generators, - typeDefinitions, - // Make sure these two are in series with each other - advancedCompile, - advancedCompileInBrowser -]; - -export const test = gulp.series(...tasks, reportTestResult); - - diff --git a/packages/blockly/tests/generators/test_generators.mjs b/packages/blockly/tests/generators/test_generators.mjs new file mode 100644 index 00000000000..8395d3d5c17 --- /dev/null +++ b/packages/blockly/tests/generators/test_generators.mjs @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Tests the Blockly generators inside a browser and reports the + * results. + */ + +import {spawnSync} from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import {rimraf} from 'rimraf'; +import { + ANSI_RESET, + BOLD_GREEN, + BOLD_RED, + runTestFunction, +} from '../scripts/test_helpers.mjs'; + +export const OUTPUT_DIR = 'build/generators'; +export const GOLDEN_DIR = 'tests/generators/golden'; + +/** + * Helper method for checking the result of generator. + * @param {string} suffix Target suffix. + * @return {number} Check result (0: success / 1: failed). + */ +export function checkResult(suffix) { + const fileName = `generated.${suffix}`; + const resultFileName = path.posix.join(OUTPUT_DIR, fileName); + + const SUCCESS_PREFIX = `${BOLD_GREEN}SUCCESS:${ANSI_RESET}`; + const FAILURE_PREFIX = `${BOLD_RED}FAILED:${ANSI_RESET}`; + + if (fs.existsSync(resultFileName)) { + const goldenFileName = path.posix.join(GOLDEN_DIR, fileName); + if (fs.existsSync(goldenFileName)) { + if (compareFile(resultFileName, goldenFileName)) { + console.log( + `${SUCCESS_PREFIX} ${suffix}: ` + + `${resultFileName} matches ${goldenFileName}`, + ); + return 0; + } else { + console.log( + `${FAILURE_PREFIX} ${suffix}: ` + + `${resultFileName} does not match ${goldenFileName}`, + ); + } + } else { + console.log(`File ${goldenFileName} not found!`); + } + } else { + console.log(`File ${resultFileName} not found!`); + } + return 1; +} + +/** + * Helper method for comparison file. + * @param {string} file1 First target file. + * @param {string} file2 Second target file. + * @return {boolean} Comparison result (true: same / false: different). + */ +function compareFile(file1, file2) { + const buf1 = fs.readFileSync(file1); + const buf2 = fs.readFileSync(file2); + // Normalize the line feed. + const code1 = buf1.toString().replace(/(?:\r\n|\r|\n)/g, '\n'); + const code2 = buf2.toString().replace(/(?:\r\n|\r|\n)/g, '\n'); + return code1 === code2; +} + +/** + * Run generator tests inside a browser and check the results. + * @return {Promise} Asynchronous result. + */ +export async function generators() { + return runTestFunction('generators', async () => { + // Clean up. + rimraf.sync(OUTPUT_DIR); + fs.mkdirSync(OUTPUT_DIR); + + const result = spawnSync( + 'node', + ['tests/generators/webdriver.js', OUTPUT_DIR], + { + stdio: 'inherit', + env: process.env, + }, + ); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error('Generator browser tests failed.'); + } + + const generatorSuffixes = ['js', 'py', 'dart', 'lua', 'php']; + let failed = 0; + generatorSuffixes.forEach((suffix) => { + failed += checkResult(suffix); + }); + + if (failed === 0) { + console.log(`${BOLD_GREEN}All generator tests passed.${ANSI_RESET}`); + } else { + console.log( + `${BOLD_RED}Failures in ${failed} generator tests.${ANSI_RESET}`, + ); + throw new Error('Generator tests failed.'); + } + }); +} + +await generators(); diff --git a/packages/blockly/tests/scripts/test_helpers.mjs b/packages/blockly/tests/scripts/test_helpers.mjs new file mode 100644 index 00000000000..e49d63698aa --- /dev/null +++ b/packages/blockly/tests/scripts/test_helpers.mjs @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Node test scripts. + */ +import asyncDone from 'async-done'; +import {spawnSync} from 'child_process'; + +export const BOLD_GREEN = '\x1b[1;32m'; +export const BOLD_RED = '\x1b[1;31m'; +export const ANSI_RESET = '\x1b[0m'; + +/** + * Run an arbitrary function as a test. + * @param {id} id The test id/name. + * @param {function(): Promise} testFunction Any function to be run as a test. + * @return {Promise} Asynchronous result. + */ +export function runTestFunction(id, testFunction) { + return new Promise((resolve) => { + console.log('======================================='); + console.log(`== ${id}`); + + // Turn the testFunction into a Promise + const asyncFunction = new Promise((resolve, reject) => { + asyncDone(testFunction, (error, result) => { + if (error) reject(error); + resolve(result); + }); + }); + + if (process.env.CI) console.log('::group::'); + asyncFunction + .then((result) => { + if (process.env.CI) console.log('::endgroup::'); + console.log(`${BOLD_GREEN}SUCCESS:${ANSI_RESET} ${id}`); + resolve(result); + }) + .catch((err) => { + console.error(err.message); + if (process.env.CI) console.log('::endgroup::'); + console.log(`${BOLD_RED}FAILED:${ANSI_RESET} ${id}`); + // Always continue. + resolve(err); + }); + }); +} + +/** + * Helper method for running test command. + * @param {id} id The test command id/name. + * @param {string} command Command line to run. + * @return {Promise} Asynchronous result. + */ +export async function runTestCommand(id, command) { + return runTestFunction(id, async () => { + const result = spawnSync(command, { + shell: true, + stdio: 'inherit', + env: process.env, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `Command failed with exit code ${result.status}: ${command}`, + ); + } + }); +}