From dddb6f1eebf29c0985733c19276f246dfa25bd78 Mon Sep 17 00:00:00 2001 From: Arjo Bruijnes Date: Thu, 23 Jul 2026 17:08:08 +0200 Subject: [PATCH 1/8] Remove dependency utility for hardcoded widgets --- packages/command-tests/commands.js | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/packages/command-tests/commands.js b/packages/command-tests/commands.js index adfeeeb0..4d3994b9 100644 --- a/packages/command-tests/commands.js +++ b/packages/command-tests/commands.js @@ -193,9 +193,6 @@ async function main() { widgetPackageJson = await readJson(join(workDir, "package.json")); widgetPackageJson.devDependencies["@mendix/pluggable-widgets-tools"] = toolsPackagePath; - // Adds compatibility to new React 18 and React native 0.72 - fixPackageJson(widgetPackageJson); - await writeJson(join(workDir, "package.json"), widgetPackageJson); await execAsync("npm install --loglevel=error", workDir, logger); @@ -400,24 +397,3 @@ async function execFailedAsync(command, workDir) { throw new Error(`Expected '${command}' to fail, but it didn't!`); } -function fixPackageJson(json) { - const devDependencies = { - "@types/jest": "^29.0.0", - "@types/react": "^19.0.0", - "@types/react-native": "0.78.2", - "@types/react-dom": "^19.0.0", - "@types/react-test-renderer": "~18.0.0" - }; - const overrides = { - react: "^19.0.0", - "react-dom": "^19.0.0", - "react-native": "0.78.2" - }; - - Object.keys(devDependencies) - .filter(dep => !!json.devDependencies[dep]) - .forEach(dep => (json.devDependencies[dep] = devDependencies[dep])); - - json.overrides = overrides; - json.resolutions = overrides; -} From 59b588d13df4c8a890692c1bd570857a494640e2 Mon Sep 17 00:00:00 2001 From: Arjo Bruijnes Date: Thu, 23 Jul 2026 17:20:11 +0200 Subject: [PATCH 2/8] Use a shorter name for widgets --- packages/command-tests/commands.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/command-tests/commands.js b/packages/command-tests/commands.js index 4d3994b9..a403757d 100644 --- a/packages/command-tests/commands.js +++ b/packages/command-tests/commands.js @@ -353,7 +353,7 @@ async function main() { } function getWidgetName(platform, boilerplate, lang, version) { - return `[generated_${version.replace(".", "_")}_${lang}_${platform}_${boilerplate}]`; + return `[${version.replace(".", "_")}_${lang}_${platform}_${boilerplate}]`; } function getWidgetLogger(index, platform, boilerplate, lang, version) { From 3e63126e25e0c5499d5c660deaf537776c255f1a Mon Sep 17 00:00:00 2001 From: Arjo Bruijnes Date: Thu, 23 Jul 2026 17:22:14 +0200 Subject: [PATCH 3/8] Print error inline with other widget output --- packages/command-tests/commands.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/command-tests/commands.js b/packages/command-tests/commands.js index a403757d..2e0ba745 100644 --- a/packages/command-tests/commands.js +++ b/packages/command-tests/commands.js @@ -84,6 +84,10 @@ async function main() { return undefined; } catch (e) { logger(chalk.bold.red("Stopped with error")); + e.toString() + .split("\n") + .forEach(l => logger(chalk.red(l))); + logger(chalk.bold.red(`Widget Directory ${workDir}`)); return [config, e]; } finally { workDirs.push(workDir); @@ -102,11 +106,6 @@ async function main() { console.log("All done! Failed: %d Successful: %d", failures.length, CONFIGS.length - failures.length); - if (failures.length) { - failures.forEach(f => console.error(chalk.red(`Test for configuration ${f[0]} failed: ${f[1]}`))); - process.exit(2); - } - async function runTest(workDir, logger, platform, boilerplate, lang, version) { const isNative = platform === "native"; const widgetName = getWidgetName(platform, boilerplate, lang, version); From e3f408fe5fc5a004a19db783e9f6a8f06a944450 Mon Sep 17 00:00:00 2001 From: Arjo Bruijnes Date: Thu, 23 Jul 2026 17:22:49 +0200 Subject: [PATCH 4/8] Allow interactive users to wait with cleaning up temporary files --- packages/command-tests/commands.js | 80 ++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/packages/command-tests/commands.js b/packages/command-tests/commands.js index 2e0ba745..d376bcef 100644 --- a/packages/command-tests/commands.js +++ b/packages/command-tests/commands.js @@ -9,6 +9,7 @@ import kill from "tree-kill"; import { promisify } from "util"; import chalk from "chalk"; import helpers from "yeoman-test"; +import { createInterface } from "readline/promises"; const { copy, existsSync, readJson, writeJson } = fsExtra; const { ls, mkdir, rm, tempdir } = shelljs; @@ -46,12 +47,17 @@ if (LIMIT_TESTS) { CONFIGS.splice(1, CONFIGS.length - 2); // Remove all configs except the first and the last } +const readline = createInterface(process.stdin, process.stdout); const yeomanMutex = new Mutex(); -main().catch(e => { - console.error(e); - process.exit(1); -}); +main() + .catch(e => { + console.error(e); + process.exitCode = 1; + }) + .finally(() => { + readline.close(); + }); async function main() { console.log("Preparing..."); @@ -63,6 +69,7 @@ async function main() { const toolsPackagePath = join(pluggableWidgetsToolsPath, packOutput.trim().split(/\n/g).pop()); const workDirs = []; + const configWorkDirs = {}; const workDirSemaphore = new Semaphore(PARALLELISM); const failures = ( await Promise.all( @@ -80,31 +87,64 @@ async function main() { ); mkdir("-p", workDir); } + configWorkDirs[workDir] = config; await runTest(workDir, logger, ...config); + workDirs.push(workDir); return undefined; - } catch (e) { + } catch (error) { logger(chalk.bold.red("Stopped with error")); - e.toString() + error + .toString() .split("\n") .forEach(l => logger(chalk.red(l))); logger(chalk.bold.red(`Widget Directory ${workDir}`)); - return [config, e]; + return { config, error, workDir }; } finally { - workDirs.push(workDir); release(); } }) ) ).filter(f => f); - console.log("Cleaning up temporary files"); - try { - rm("-rf", toolsPackagePath, ...workDirs); - } catch (error) { - console.warn(chalk.yellow(`Unable to remove temporary files: ${error.message}`)); + console.log( + "\nFinished Testing: " + + chalk.bold(chalk[failures.length > 0 ? "red" : "green"]("%d Failed, ") + chalk.green("%d Successful")), + failures.length, + CONFIGS.length - failures.length + ); + + console.log(chalk.green("\nCreated %d temporary directories for test widgets"), workDirs.length); + let maxLength = 0; + Object.entries(configWorkDirs) + .map(([dir, config]) => { + const name = getWidgetName(...config); + maxLength = Math.max(maxLength, name.length); + return [dir, name]; + }) + .forEach(([dir, name]) => + console.log( + " %s\t%s\t%s", + name + repeat(" ", maxLength - name.length), + dir, + failures.some(f => f.workDir === dir) ? chalk.bold.red("❌ Error") : "" + ) + ); + + if ( + !readline.terminal || // If non-interactive, just clean up without asking. + /^y?e?s?$/i.test(await readline.question(chalk.cyan("Clean up test widgets? ") + chalk.gray("(YES/no)"))) + ) { + console.log("Cleaning up temporary files"); + try { + rm("-rf", toolsPackagePath, ...workDirs); + } catch (error) { + console.warn(chalk.yellow(`Unable to remove temporary files: ${error.message}`)); + } + } else { + console.log("Leaving temporary files"); } - console.log("All done! Failed: %d Successful: %d", failures.length, CONFIGS.length - failures.length); + console.log(chalk.bold.green("\nAll done!")); async function runTest(workDir, logger, platform, boilerplate, lang, version) { const isNative = platform === "native"; @@ -258,8 +298,8 @@ async function main() { ? "@mendix/pluggable-widgets-tools" : null : boilerplate === "full" - ? "classnames" - : null; + ? "classnames" + : null; if ( packageName && @@ -396,3 +436,11 @@ async function execFailedAsync(command, workDir) { throw new Error(`Expected '${command}' to fail, but it didn't!`); } +function repeat(element, amount) { + let result = element; + while (amount > 0) { + result += element; + amount--; + } + return result; +} From f368f6e60a14682f77e87d90745961b547183cd1 Mon Sep 17 00:00:00 2001 From: Arjo Bruijnes Date: Mon, 27 Jul 2026 14:51:04 +0200 Subject: [PATCH 5/8] Migrate to Typescript and split into modules --- packages/command-tests/commands.js | 446 --------------------- packages/command-tests/lib/config.ts | 10 + packages/command-tests/lib/errors.ts | 9 + packages/command-tests/lib/exec.ts | 26 ++ packages/command-tests/lib/logger.ts | 31 ++ packages/command-tests/lib/node.ts | 14 + packages/command-tests/lib/option.ts | 12 + packages/command-tests/lib/strings.ts | 13 + packages/command-tests/main.ts | 155 +++++++ packages/command-tests/package.json | 31 +- packages/command-tests/tests/testRunner.ts | 283 +++++++++++++ packages/command-tests/tsconfig.json | 9 + pnpm-lock.yaml | 145 +++---- 13 files changed, 628 insertions(+), 556 deletions(-) delete mode 100644 packages/command-tests/commands.js create mode 100644 packages/command-tests/lib/config.ts create mode 100644 packages/command-tests/lib/errors.ts create mode 100644 packages/command-tests/lib/exec.ts create mode 100644 packages/command-tests/lib/logger.ts create mode 100644 packages/command-tests/lib/node.ts create mode 100644 packages/command-tests/lib/option.ts create mode 100644 packages/command-tests/lib/strings.ts create mode 100644 packages/command-tests/main.ts create mode 100644 packages/command-tests/tests/testRunner.ts create mode 100644 packages/command-tests/tsconfig.json diff --git a/packages/command-tests/commands.js b/packages/command-tests/commands.js deleted file mode 100644 index d376bcef..00000000 --- a/packages/command-tests/commands.js +++ /dev/null @@ -1,446 +0,0 @@ -import { Mutex, Semaphore } from "async-mutex"; -import { exec } from "child_process"; -import { readFileSync, writeFileSync } from "fs"; -import fsExtra from "fs-extra"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; -import shelljs from "shelljs"; -import kill from "tree-kill"; -import { promisify } from "util"; -import chalk from "chalk"; -import helpers from "yeoman-test"; -import { createInterface } from "readline/promises"; - -const { copy, existsSync, readJson, writeJson } = fsExtra; -const { ls, mkdir, rm, tempdir } = shelljs; - -const LIMIT_TESTS = !!process.env.LIMIT_TESTS; -const PARALLELISM = 4; - -const DIR_COMMAND_TESTS = dirname(fileURLToPath(import.meta.url)); - -const CONFIGS = [ - ["web", "full", "js", "latest"], - ["web", "full", "ts", "latest"], - ["native", "full", "js", "latest"], - ["native", "full", "ts", "latest"], - ["web", "empty", "js", "latest"], - ["web", "empty", "ts", "latest"], - ["native", "empty", "js", "latest"], - ["native", "empty", "ts", "latest"] -]; - -const COLORS = [ - "green", - "yellow", - "blue", - "magenta", - "cyan", - "greenBright", - "yellowBright", - "blueBright", - "magentaBright", - "cyanBright" -]; - -if (LIMIT_TESTS) { - CONFIGS.splice(1, CONFIGS.length - 2); // Remove all configs except the first and the last -} - -const readline = createInterface(process.stdin, process.stdout); -const yeomanMutex = new Mutex(); - -main() - .catch(e => { - console.error(e); - process.exitCode = 1; - }) - .finally(() => { - readline.close(); - }); - -async function main() { - console.log("Preparing..."); - - const pluggableWidgetsToolsPath = join(DIR_COMMAND_TESTS, "../pluggable-widgets-tools"); - const pluggableWidgetsToolsVersion = (await readJson(join(pluggableWidgetsToolsPath, "package.json"))).version; - console.log("Preparing: Packaging @mendix/pluggable-widgets-tools version %s", pluggableWidgetsToolsVersion); - const { stdout: packOutput } = await execAsync("npm pack", pluggableWidgetsToolsPath, m => console.log(m)); - const toolsPackagePath = join(pluggableWidgetsToolsPath, packOutput.trim().split(/\n/g).pop()); - - const workDirs = []; - const configWorkDirs = {}; - const workDirSemaphore = new Semaphore(PARALLELISM); - const failures = ( - await Promise.all( - CONFIGS.map(async (config, index) => { - const logger = getWidgetLogger(index, ...config); - logger("Scheduled, waiting for lock"); - const [, release] = await workDirSemaphore.acquire(); - let workDir; - try { - workDir = workDirs.pop(); - if (!workDir) { - workDir = join( - index === 0 ? join(tempdir(), "spaced folder") : tempdir(), - `pwt_test_${Math.round(Math.random() * 10000)}` - ); - mkdir("-p", workDir); - } - configWorkDirs[workDir] = config; - await runTest(workDir, logger, ...config); - workDirs.push(workDir); - return undefined; - } catch (error) { - logger(chalk.bold.red("Stopped with error")); - error - .toString() - .split("\n") - .forEach(l => logger(chalk.red(l))); - logger(chalk.bold.red(`Widget Directory ${workDir}`)); - return { config, error, workDir }; - } finally { - release(); - } - }) - ) - ).filter(f => f); - - console.log( - "\nFinished Testing: " + - chalk.bold(chalk[failures.length > 0 ? "red" : "green"]("%d Failed, ") + chalk.green("%d Successful")), - failures.length, - CONFIGS.length - failures.length - ); - - console.log(chalk.green("\nCreated %d temporary directories for test widgets"), workDirs.length); - let maxLength = 0; - Object.entries(configWorkDirs) - .map(([dir, config]) => { - const name = getWidgetName(...config); - maxLength = Math.max(maxLength, name.length); - return [dir, name]; - }) - .forEach(([dir, name]) => - console.log( - " %s\t%s\t%s", - name + repeat(" ", maxLength - name.length), - dir, - failures.some(f => f.workDir === dir) ? chalk.bold.red("❌ Error") : "" - ) - ); - - if ( - !readline.terminal || // If non-interactive, just clean up without asking. - /^y?e?s?$/i.test(await readline.question(chalk.cyan("Clean up test widgets? ") + chalk.gray("(YES/no)"))) - ) { - console.log("Cleaning up temporary files"); - try { - rm("-rf", toolsPackagePath, ...workDirs); - } catch (error) { - console.warn(chalk.yellow(`Unable to remove temporary files: ${error.message}`)); - } - } else { - console.log("Leaving temporary files"); - } - - console.log(chalk.bold.green("\nAll done!")); - - async function runTest(workDir, logger, platform, boilerplate, lang, version) { - const isNative = platform === "native"; - const widgetName = getWidgetName(platform, boilerplate, lang, version); - let widgetPackageJson; - - logger(`Preparing widget...`); - await prepareWidget(); - logger(`Ready to test!`); - - logger(`Testing linting...`); - await testLint(); - - // Temporarily disabled due to bizarre typing issues in the CI that cannot be reproduced in any local environment - logger(`Testing unit tests....`); - await testTest(); - - if (LIMIT_TESTS) { - logger(`Quick tested!`); - return; - } - - logger(`Testing 'build' command...`); - await testBuild(); - - logger(`Testing 'release' command...`); - await testRelease(); - - logger(`Checking dependencies files...`); - await checkDependenciesFiles(isNative, boilerplate, version); - - logger(`Testing npm start...`); - await testStart(); - - // Check native dependency management - if (isNative) { - logger(`Testing native dependency management...`); - await testNativeDependencyManagement(); - } - - logger(`Tested!`); - - async function prepareWidget() { - const filesToRemove = ls(workDir) - .filter(file => file !== "node_modules") - .map(file => join(workDir, file)); - - if (filesToRemove.length) { - rm("-r", ...filesToRemove); - } - - if (version === "latest") { - logger("Preparing widget: Generating"); - const promptAnswers = { - name: "Generated", - description: "My widget description", - organization: "com.mendix", - copyright: "Mendix 2020", - license: "Apache-2.0", - version: "1.0.0", - author: "Widget Generator", - projectPath: "./dist/MxTestProject", - programmingLanguage: lang === "ts" ? "typescript" : "javascript", - platform, - boilerplate, - hasUnitTests: true, - hasE2eTests: false - }; - const release = await yeomanMutex.acquire(); // yeoman generator is no re-entrable :( - try { - const generatedWidget = await helpers - .run(resolveModule("@mendix/generator-widget")) - .withAnswers(promptAnswers) - .withArguments("Generated") - .toPromise(); - await copy(join(generatedWidget.cwd, "generated"), workDir); - } finally { - release(); - } - } else { - await copy(join(DIR_COMMAND_TESTS, "projects", widgetName), workDir); - } - - logger("Preparing widget: Installing"); - widgetPackageJson = await readJson(join(workDir, "package.json")); - widgetPackageJson.devDependencies["@mendix/pluggable-widgets-tools"] = toolsPackagePath; - - await writeJson(join(workDir, "package.json"), widgetPackageJson); - - await execAsync("npm install --loglevel=error", workDir, logger); - } - - async function testLint() { - await execFailedAsync("npm run lint", workDir); - await execAsync("npm run lint:fix", workDir, logger); - await execAsync("npm run lint", workDir, logger); - } - - async function testTest() { - if (platform === "native") { - await execFailedAsync("npm test -- --forceExit", workDir); - await execAsync("npm test -- -u --forceExit", workDir, logger); - } else { - await execAsync("npm test -- --forceExit", workDir, logger); - } - } - - async function testBuild() { - await execAsync("npm run build", workDir, logger); - checkWidgetBundleFiles(); - } - - async function testRelease() { - rm("-rf", join(workDir, "dist")); - await execAsync("npm run release", workDir, logger); - checkWidgetBundleFiles(); - } - - function checkWidgetBundleFiles() { - const stagingDir = join(workDir, "dist", "tmp", "widgets"); - const mpkFile = join( - workDir, - "dist", - widgetPackageJson.version, - `${widgetPackageJson.packagePath}.${widgetPackageJson.widgetName}.mpk` - ); - const requiredFiles = [ - mpkFile, - join(stagingDir, "package.xml"), - join(stagingDir, `${widgetPackageJson.widgetName}.xml`) - ]; - const missing = requiredFiles.filter(f => !existsSync(f)); - if (missing.length) { - throw new Error(`Expected widget bundle files in mpk, but missing: ${missing.join(", ")}.`); - } - } - - async function checkDependenciesFiles(isNative, boilerplate, version) { - const dependenciesJSONFile = join(workDir, "dist", "tmp", "widgets", "dependencies.json"); - const dependenciesTxtFile = join(workDir, "dist", "tmp", "widgets", "dependencies.txt"); - - if (!existsSync(dependenciesJSONFile) || !existsSync(dependenciesTxtFile)) { - throw new Error("Expected dependencies files to be generated, but it wasn't."); - } - - const dependenciesJSON = JSON.parse(readFileSync(dependenciesJSONFile, "utf8")); - const dependenciesText = readFileSync(dependenciesTxtFile, "utf8"); - - const packageName = isNative - ? version === "latest" - ? "@mendix/pluggable-widgets-tools" - : null - : boilerplate === "full" - ? "classnames" - : null; - - if ( - packageName && - (!dependenciesJSON.some(dependency => Object.keys(dependency)[0].includes(packageName)) || - !dependenciesText.includes(packageName)) - ) { - throw new Error(`The "${packageName}" could not be found in the dependencies files.`); - } else if ( - !packageName && - (dependenciesJSON.length !== 0 || - !(dependenciesText.includes("No third parties dependencies") || dependenciesText === "")) - ) { - throw new Error("Unexpected content in dependencies files."); - } - } - - async function testStart() { - const startProcess = exec("npm start", { cwd: workDir, env: { ...process.env, NO_COLOR: "true" } }); - - try { - await new Promise((resolve, reject) => { - let inProgress = false; - startProcess.stdout.on("data", onOutput); - startProcess.stderr.on("data", onOutput); - startProcess.on("exit", exitCode => { - reject(new Error(`Exited with status ${exitCode}`)); - }); - function onOutput(data) { - if (/error/i.test(data)) { - reject(new Error(`Received error ${data}`)); - } else if (/\bbundles /.test(data)) { - inProgress = true; - } else if (/\bcreated .* in /.test(data)) { - inProgress = false; - setTimeout(() => { - if (!inProgress) { - logger(`Start succeeded!`); - resolve(); - } - }, 100); - } - } - }); - } finally { - try { - await promisify(kill)(startProcess.pid, "SIGKILL"); - } catch (e) { - console.warn(`[${widgetName}] Error while killing start process: ${e}`); - } - await new Promise(resolve => setTimeout(resolve, 5000)); // give time for processes to die - } - } - - async function testNativeDependencyManagement() { - const NATIVE_MAPS_VERSION = "0.31.1"; - await execAsync(`npm install react-native-maps@${NATIVE_MAPS_VERSION}`, workDir, logger); - const entryPointPath = join(workDir, "src", `Generated.${lang}x`); - const jsonPath = join(workDir, `/dist/tmp/widgets/${widgetPackageJson.widgetName}.json`); - const fileData = readFileSync(entryPointPath); - writeFileSync( - entryPointPath, - Buffer.concat([ - Buffer.from(`import "react-native-maps"; -`), - Buffer.from(fileData) - ]) - ); - await execAsync("npm run build", workDir, logger); - if (!existsSync(jsonPath)) { - throw new Error("Expected dependency json file to be generated, but it wasn't."); - } - const dependencyJson = await readJson(jsonPath); - if ( - !dependencyJson.nativeDependencies || - dependencyJson.nativeDependencies["react-native-maps"] !== NATIVE_MAPS_VERSION - ) { - throw new Error("Expected dependency json file to contain dependencies, but it wasn't."); - } - if (!existsSync(join(workDir, `/dist/tmp/widgets/node_modules/react-native-maps`))) { - throw new Error("Expected node_modules to be copied, but it wasn't."); - } - if ( - !existsSync(join(workDir, `/dist/tmp/widgets/node_modules/react-native-maps/node_modules/prop-types`)) - ) { - throw new Error("Expected transitive node_modules to be copied, but it wasn't."); - } - logger(`Native dependency management succeeded!`); - } - } -} - -function getWidgetName(platform, boilerplate, lang, version) { - return `[${version.replace(".", "_")}_${lang}_${platform}_${boilerplate}]`; -} - -function getWidgetLogger(index, platform, boilerplate, lang, version) { - const color = chalk[COLORS[index % COLORS.length]]; - return (...msgs) => console.log(color(getWidgetName(platform, boilerplate, lang, version)), ...msgs); -} - -/** - * Resolves the given module and returns the full path. - * This corresponds to the "main" or "import" property of a package.json. - * - * @example - * ```js - * resolve("@mendix/generator-widget") // "/path/to/generator-widget/generators/app/index.js" - * ``` - */ -function resolveModule(packageName) { - return fileURLToPath(import.meta.resolve(packageName)); -} - -async function execAsync(command, workDir, logger) { - const resultPromise = promisify(exec)(command, { cwd: workDir }); - while (true) { - const waitPromise = new Promise(resolve => setTimeout(resolve, 60 * 1000)); - - const haveCompleted = await Promise.race([resultPromise.then(() => true), waitPromise.then(() => false)]); - if (haveCompleted) { - return resultPromise; - } - logger("Waiting..."); - } -} - -async function execFailedAsync(command, workDir) { - try { - await promisify(exec)(command, { cwd: workDir }); - // eslint-disable-next-line no-unused-vars - } catch (_) { - return; - } - throw new Error(`Expected '${command}' to fail, but it didn't!`); -} - -function repeat(element, amount) { - let result = element; - while (amount > 0) { - result += element; - amount--; - } - return result; -} diff --git a/packages/command-tests/lib/config.ts b/packages/command-tests/lib/config.ts new file mode 100644 index 00000000..aaecb17d --- /dev/null +++ b/packages/command-tests/lib/config.ts @@ -0,0 +1,10 @@ +export type Config = [ + platform: "web" | "native", + boilerplate: "full" | "empty", + language: "ts" | "js", + version: "latest" +]; + +export function getWidgetName(...[platform, boilerplate, lang, version]: Config): string { + return `[${version.replace(".", "_")}_${lang}_${platform}_${boilerplate}]`; +} diff --git a/packages/command-tests/lib/errors.ts b/packages/command-tests/lib/errors.ts new file mode 100644 index 00000000..b83f3cee --- /dev/null +++ b/packages/command-tests/lib/errors.ts @@ -0,0 +1,9 @@ +export function ensureError(input: unknown): Error { + if (input instanceof Error) { + return input; + } + if (typeof input === "string") { + return new Error(input); + } + return new Error(`Unknown Error: ${input}`); +} diff --git a/packages/command-tests/lib/exec.ts b/packages/command-tests/lib/exec.ts new file mode 100644 index 00000000..0490617b --- /dev/null +++ b/packages/command-tests/lib/exec.ts @@ -0,0 +1,26 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { type Logger } from "./logger.ts"; + +export async function execAsync(command: string, workDir: string, logger: Logger) { + const resultPromise = promisify(exec)(command, { cwd: workDir }); + while (true) { + const waitPromise = new Promise(resolve => setTimeout(resolve, 60 * 1000)); + + const haveCompleted = await Promise.race([resultPromise.then(() => true), waitPromise.then(() => false)]); + if (haveCompleted) { + return resultPromise; + } + logger("Waiting..."); + } +} + +export async function execFailedAsync(command: string, workDir: string) { + try { + await promisify(exec)(command, { cwd: workDir }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + return; + } + throw new Error(`Expected '${command}' to fail, but it didn't!`); +} diff --git a/packages/command-tests/lib/logger.ts b/packages/command-tests/lib/logger.ts new file mode 100644 index 00000000..737c2cd6 --- /dev/null +++ b/packages/command-tests/lib/logger.ts @@ -0,0 +1,31 @@ +import { format, styleText } from "node:util"; +import { type Config, getWidgetName } from "./config.ts"; + +type Arguments = F extends (...args: infer Args) => unknown ? Args : never; +type TextStyles = Arguments[0]; + +const COLORS: TextStyles[] = [ + "green", + "yellow", + "blue", + "magenta", + "cyan", + "bgGreen", + "bgYellow", + "bgBlue", + "bgMagenta", + "bgCyan" +]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Logger = (template: string, ...msgs: any[]) => void; + +export function getWidgetLogger(index: number, ...[platform, boilerplate, lang, version]: Config): Logger { + const colorName = COLORS[index % COLORS.length]; + return (template, ...msgs) => + console.log( + "%s %s", + styleText(colorName, getWidgetName(platform, boilerplate, lang, version)), + format(template, ...msgs) + ); +} diff --git a/packages/command-tests/lib/node.ts b/packages/command-tests/lib/node.ts new file mode 100644 index 00000000..70242303 --- /dev/null +++ b/packages/command-tests/lib/node.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; + +/** + * Resolves the given module and returns the full path. + * This corresponds to the "main" or "import" property of a package.json. + * + * @example + * ```js + * resolve("@mendix/generator-widget") // "/path/to/generator-widget/generators/app/index.js" + * ``` + */ +export function resolveModule(packageName: string): string { + return fileURLToPath(import.meta.resolve(packageName)); +} diff --git a/packages/command-tests/lib/option.ts b/packages/command-tests/lib/option.ts new file mode 100644 index 00000000..36ce2dcb --- /dev/null +++ b/packages/command-tests/lib/option.ts @@ -0,0 +1,12 @@ +export type Option = T | undefined; + +export function ensure(input: T | undefined): T { + if (isDefined(input)) { + return input; + } + throw Error("Unexpected value of undefined"); +} + +export function isDefined(input: T | undefined): input is T { + return input !== undefined; +} diff --git a/packages/command-tests/lib/strings.ts b/packages/command-tests/lib/strings.ts new file mode 100644 index 00000000..3f2fa00b --- /dev/null +++ b/packages/command-tests/lib/strings.ts @@ -0,0 +1,13 @@ +/*** + * @param element The element to repeat + * @param amount The amount of times to repeat the element + * @returns String containing the element amount times. + */ +export function repeat(element: string, amount: number) { + let result = element; + while (amount > 0) { + result += element; + amount--; + } + return result; +} diff --git a/packages/command-tests/main.ts b/packages/command-tests/main.ts new file mode 100644 index 00000000..8969ae23 --- /dev/null +++ b/packages/command-tests/main.ts @@ -0,0 +1,155 @@ +#! /usr/bin/env node --experimental-strip-types +import { Mutex, Semaphore } from "async-mutex"; +import fsExtra from "fs-extra"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import shelljs from "shelljs"; +import { createInterface } from "readline/promises"; +import { execAsync } from "./lib/exec.ts"; +import { ensure, isDefined } from "./lib/option.ts"; +import { getWidgetLogger } from "./lib/logger.ts"; +import { type Config, getWidgetName } from "./lib/config.ts"; +import { repeat } from "./lib/strings.ts"; +import { runTest } from "./tests/testRunner.ts"; +import { ensureError } from "./lib/errors.ts"; +import { styleText } from "node:util"; + +const { readJson } = fsExtra; +const { mkdir, rm, tempdir } = shelljs; + +const LIMIT_TESTS = !!process.env.LIMIT_TESTS; +const PARALLELISM = 4; + +const DIR_COMMAND_TESTS = dirname(fileURLToPath(import.meta.url)); + +const CONFIGS: Config[] = [ + ["web", "full", "js", "latest"], + ["web", "full", "ts", "latest"], + ["native", "full", "js", "latest"], + ["native", "full", "ts", "latest"], + ["web", "empty", "js", "latest"], + ["web", "empty", "ts", "latest"], + ["native", "empty", "js", "latest"], + ["native", "empty", "ts", "latest"] +]; + +if (LIMIT_TESTS) { + CONFIGS.splice(1, CONFIGS.length - 2); // Remove all configs except the first and the last +} + +const readline = createInterface(process.stdin, process.stdout); +const yeomanMutex = new Mutex(); + +main() + .catch(e => { + console.error(e); + process.exitCode = 1; + }) + .finally(() => { + readline.close(); + }); + +async function main() { + console.log("Preparing..."); + + const pluggableWidgetsToolsPath = join(DIR_COMMAND_TESTS, "../pluggable-widgets-tools"); + const pluggableWidgetsToolsVersion = (await readJson(join(pluggableWidgetsToolsPath, "package.json"))).version; + console.log("Preparing: Packaging @mendix/pluggable-widgets-tools version %s", pluggableWidgetsToolsVersion); + const { stdout: packOutput } = await execAsync("npm pack", pluggableWidgetsToolsPath, (m: string) => + console.log(m) + ); + const widgetsToolsPackagePath = join(pluggableWidgetsToolsPath, ensure(packOutput.trim().split(/\n/g).pop())); + + const workDirs: string[] = []; + const configWorkDirs: Record = {}; + const workDirSemaphore = new Semaphore(PARALLELISM); + const failures: Array<{ config: Config; error: Error; workDir: string }> = ( + await Promise.all( + CONFIGS.map(async (config, index) => { + const logger = getWidgetLogger(index, ...config); + logger("Scheduled, waiting for lock"); + const [, release] = await workDirSemaphore.acquire(); + let workDir; + try { + workDir = workDirs.pop(); + if (!workDir) { + workDir = join( + index === 0 ? join(tempdir(), "spaced folder") : tempdir(), + `pwt_test_${Math.round(Math.random() * 10000)}` + ); + mkdir("-p", workDir); + } + configWorkDirs[workDir] = config; + await runTest(config, { + logger, + yeomanMutex, + workDir, + widgetsToolsPackagePath, + isShortRun: LIMIT_TESTS, + commandTestsDir: DIR_COMMAND_TESTS + }); + workDirs.push(workDir); + return undefined; + } catch (e) { + logger(styleText(["bold", "red"], "Stopped with error")); + const error = ensureError(e); + error + .toString() + .split("\n") + .forEach(l => logger(styleText("red", l))); + logger(styleText(["bold", "red"], `Widget Directory ${workDir}`)); + return { config, error, workDir: workDir ?? "" }; + } finally { + release(); + } + }) + ) + ).filter(isDefined); + + console.log( + "\nFinished Testing: " + + styleText( + "bold", + styleText(failures.length > 0 ? "red" : "green", "%d Failed, ") + styleText("green", "%d Successful") + ), + failures.length, + CONFIGS.length - failures.length + ); + + console.log(styleText("green", "\nCreated %d temporary directories during testing"), workDirs.length); + let maxDirLength = 0, + maxNameLength = 0; + Object.entries(configWorkDirs) + .map(([dir, config]) => { + const name = getWidgetName(...config); + maxDirLength = Math.max(maxDirLength, dir.length); + maxNameLength = Math.max(maxNameLength, name.length); + return [dir, name]; + }) + .forEach(([dir, name]) => + console.log( + " %s %s %s", + dir + repeat(" ", maxDirLength - dir.length), + name + repeat(" ", maxNameLength - name.length), + failures.some(f => f.workDir === dir) ? styleText(["bold", "red"], "❌ Error") : "" + ) + ); + + if ( + !readline.terminal || // If non-interactive, just clean up without asking. + /^y?e?s?$/i.test( + await readline.question(styleText("cyan", "Clean up test widgets? ") + styleText("gray", "(YES/no)")) + ) + ) { + console.log("Cleaning up temporary files"); + try { + rm("-rf", widgetsToolsPackagePath, ...workDirs); + } catch (error) { + console.warn(styleText("yellow", `Unable to remove temporary files: ${ensureError(error).message}`)); + } + } else { + console.log("Leaving temporary files"); + } + + console.log(styleText(["bold", "green"], "\nAll done!")); +} diff --git a/packages/command-tests/package.json b/packages/command-tests/package.json index 74edf49c..eaf3b547 100644 --- a/packages/command-tests/package.json +++ b/packages/command-tests/package.json @@ -4,33 +4,32 @@ "description": "Cli tests for widgets generator and tools", "private": true, "type": "module", - "main": "commands.js", + "main": "main.ts", "scripts": { - "start": "node commands.js" + "start": "node --experimental-strip-types main.ts", + "test": "tsc --noEmit" }, - "devDependencies": { + "engines": { + "node": "^22.18.0" + }, + "dependencies": { "@mendix/generator-widget": "workspace:*", "@mendix/pluggable-widgets-tools": "workspace:*", - "@prettier/plugin-xml": "^2.2.0", - "@types/jest-image-snapshot": "^4.3.1", - "@types/node": "^20.14.8", - "@types/shelljs": "^0.10.0", - "@types/xml2js": "^0.4.5", "async-mutex": "^0.5.0", - "chalk": "^5.6.2", - "eslint-config-prettier": "^8.5.0", "fs-extra": "^11.3.4", - "globals": "^17.6.0", - "identity-obj-proxy": "^3.0.0", - "peggy": "^1.2.0", "shelljs": "^0.10.0", - "shx": "^0.4.0", "tree-kill": "^1.2.2", - "ts-node": "^10.9.1", - "typescript": "^4.8.4", "yeoman-environment": "^6.0.1", "yeoman-test": "^11.3.1" }, + "devDependencies": { + "@tsconfig/node-ts": "^23.6.4", + "@tsconfig/node22": "^22.0.5", + "@types/fs-extra": "^11.0.4", + "@types/node": "^20.14.8", + "@types/shelljs": "^0.10.0", + "typescript": "^5.9.3" + }, "keywords": [], "author": "", "license": "ISC" diff --git a/packages/command-tests/tests/testRunner.ts b/packages/command-tests/tests/testRunner.ts new file mode 100644 index 00000000..55a94f23 --- /dev/null +++ b/packages/command-tests/tests/testRunner.ts @@ -0,0 +1,283 @@ +import shelljs from "shelljs"; +import kill from "tree-kill"; +import { type Config, getWidgetName } from "../lib/config.ts"; +import { type Logger } from "../lib/logger.ts"; +import { join } from "node:path"; +import { resolveModule } from "../lib/node.ts"; +import fsExtra from "fs-extra"; +import { execAsync, execFailedAsync } from "../lib/exec.ts"; +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { Mutex } from "async-mutex"; +import helpers from "yeoman-test"; + +const { copy, existsSync, readFileSync, readJson, writeFileSync, writeJson } = fsExtra; +const { ls, rm } = shelljs; + +export async function runTest( + config: Config, + { + logger, + yeomanMutex, + workDir, + commandTestsDir, + widgetsToolsPackagePath, + isShortRun + }: { + logger: Logger; + yeomanMutex: Mutex; + workDir: string; + isShortRun: boolean; + commandTestsDir: string; + widgetsToolsPackagePath: string; + } +) { + const [platform, boilerplate, lang, version] = config; + const isNative = platform === "native"; + const widgetName = getWidgetName(platform, boilerplate, lang, version); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let widgetPackageJson: any; + + logger(`Preparing widget...`); + await prepareWidget(); + logger(`Ready to test!`); + + logger(`Testing linting...`); + await testLint(); + + // Temporarily disabled due to bizarre typing issues in the CI that cannot be reproduced in any local environment + logger(`Testing unit tests....`); + await testTest(); + + if (isShortRun) { + logger(`Quick tested!`); + return; + } + + logger(`Testing 'build' command...`); + await testBuild(); + + logger(`Testing 'release' command...`); + await testRelease(); + + logger(`Checking dependencies files...`); + await checkDependenciesFiles(isNative, boilerplate, version); + + logger(`Testing npm start...`); + await testStart(); + + // Check native dependency management + if (isNative) { + logger(`Testing native dependency management...`); + await testNativeDependencyManagement(); + } + + logger(`Tested!`); + + async function prepareWidget() { + const filesToRemove = ls(workDir) + .filter(file => file !== "node_modules") + .map(file => join(workDir, file)); + + if (filesToRemove.length) { + rm("-r", ...filesToRemove); + } + + if (version === "latest") { + logger("Preparing widget: Generating"); + const promptAnswers = { + name: "Generated", + description: "My widget description", + organization: "com.mendix", + copyright: "Mendix 2020", + license: "Apache-2.0", + version: "1.0.0", + author: "Widget Generator", + projectPath: "./dist/MxTestProject", + programmingLanguage: lang === "ts" ? "typescript" : "javascript", + platform, + boilerplate, + hasUnitTests: true, + hasE2eTests: false + }; + const release = await yeomanMutex.acquire(); // yeoman generator is no re-entrable :( + try { + const generatedWidget = await helpers + .run(resolveModule("@mendix/generator-widget")) + .withAnswers(promptAnswers) + .withArguments("Generated") + .run(); + await copy(join(generatedWidget.cwd, "generated"), workDir); + } finally { + release(); + } + } else { + await copy(join(commandTestsDir, "projects", widgetName), workDir); + } + + logger("Preparing widget: Installing"); + widgetPackageJson = await readJson(join(workDir, "package.json")); + widgetPackageJson.devDependencies["@mendix/pluggable-widgets-tools"] = widgetsToolsPackagePath; + + await writeJson(join(workDir, "package.json"), widgetPackageJson); + + await execAsync("npm install --loglevel=error", workDir, logger); + } + + async function testLint() { + await execFailedAsync("npm run lint", workDir); + await execAsync("npm run lint:fix", workDir, logger); + await execAsync("npm run lint", workDir, logger); + } + + async function testTest() { + if (platform === "native") { + await execFailedAsync("npm test -- --forceExit", workDir); + await execAsync("npm test -- -u --forceExit", workDir, logger); + } else { + await execAsync("npm test -- --forceExit", workDir, logger); + } + } + + async function testBuild() { + await execAsync("npm run build", workDir, logger); + checkWidgetBundleFiles(); + } + + async function testRelease() { + rm("-rf", join(workDir, "dist")); + await execAsync("npm run release", workDir, logger); + checkWidgetBundleFiles(); + } + + function checkWidgetBundleFiles() { + const stagingDir = join(workDir, "dist", "tmp", "widgets"); + const mpkFile = join( + workDir, + "dist", + widgetPackageJson.version, + `${widgetPackageJson.packagePath}.${widgetPackageJson.widgetName}.mpk` + ); + const requiredFiles = [ + mpkFile, + join(stagingDir, "package.xml"), + join(stagingDir, `${widgetPackageJson.widgetName}.xml`) + ]; + const missing = requiredFiles.filter(f => !existsSync(f)); + if (missing.length) { + throw new Error(`Expected widget bundle files in mpk, but missing: ${missing.join(", ")}.`); + } + } + + async function checkDependenciesFiles(isNative: boolean, boilerplate: Config[1], version: Config[3]) { + const dependenciesJSONFile = join(workDir, "dist", "tmp", "widgets", "dependencies.json"); + const dependenciesTxtFile = join(workDir, "dist", "tmp", "widgets", "dependencies.txt"); + + if (!existsSync(dependenciesJSONFile) || !existsSync(dependenciesTxtFile)) { + throw new Error("Expected dependencies files to be generated, but it wasn't."); + } + + type Dependency = { [packageName: string]: { version: string; url: string | null } }; + const dependenciesJSON: Dependency[] = JSON.parse(readFileSync(dependenciesJSONFile, "utf8")); + const dependenciesText = readFileSync(dependenciesTxtFile, "utf8"); + + const packageName = isNative + ? version === "latest" + ? "@mendix/pluggable-widgets-tools" + : null + : boilerplate === "full" + ? "classnames" + : null; + + if ( + packageName && + (!dependenciesJSON.some(dependency => Object.keys(dependency).includes(packageName)) || + !dependenciesText.includes(packageName)) + ) { + throw new Error(`The "${packageName}" could not be found in the dependencies files.`); + } else if ( + !packageName && + (dependenciesJSON.length !== 0 || + !(dependenciesText.includes("No third parties dependencies") || dependenciesText === "")) + ) { + throw new Error("Unexpected content in dependencies files."); + } + } + + async function testStart() { + const startProcess = exec("npm start", { cwd: workDir, env: { ...process.env, NO_COLOR: "true" } }); + + try { + await new Promise((resolve, reject) => { + let inProgress = false; + startProcess.stdout?.on("data", onOutput); + startProcess.stderr?.on("data", onOutput); + startProcess.on("exit", exitCode => { + reject(new Error(`Exited with status ${exitCode}`)); + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function onOutput(data: any) { + if (/error/i.test(data)) { + reject(new Error(`Received error ${data}`)); + } else if (/\bbundles /.test(data)) { + inProgress = true; + } else if (/\bcreated .* in /.test(data)) { + inProgress = false; + setTimeout(() => { + if (!inProgress) { + logger(`Start succeeded!`); + resolve(); + } + }, 100); + } + } + }); + } finally { + if (startProcess.pid !== undefined) { + try { + await promisify((pid: number, signal: string, callback: (e?: Error) => void) => + kill(pid, signal, callback) + )(startProcess.pid, "SIGKILL"); + } catch (e) { + throw new Error("Unable to kill `start` process", { cause: e }); + } + logger("Waiting for process to terminate"); + await new Promise(resolve => setTimeout(resolve, 5000)); // give time for processes to die + } + } + } + + async function testNativeDependencyManagement() { + const NATIVE_MAPS_VERSION = "0.31.1"; + await execAsync(`npm install react-native-maps@${NATIVE_MAPS_VERSION}`, workDir, logger); + const entryPointPath = join(workDir, "src", `Generated.${lang}x`); + const jsonPath = join(workDir, `/dist/tmp/widgets/${widgetPackageJson.widgetName}.json`); + const fileData = readFileSync(entryPointPath); + writeFileSync( + entryPointPath, + Buffer.concat([ + Buffer.from(`import "react-native-maps"; +`), + Buffer.from(fileData) + ]) + ); + await execAsync("npm run build", workDir, logger); + if (!existsSync(jsonPath)) { + throw new Error("Expected dependency json file to be generated, but it wasn't."); + } + const dependencyJson = await readJson(jsonPath); + if ( + !dependencyJson.nativeDependencies || + dependencyJson.nativeDependencies["react-native-maps"] !== NATIVE_MAPS_VERSION + ) { + throw new Error("Expected dependency json file to contain dependencies, but it wasn't."); + } + if (!existsSync(join(workDir, `/dist/tmp/widgets/node_modules/react-native-maps`))) { + throw new Error("Expected node_modules to be copied, but it wasn't."); + } + if (!existsSync(join(workDir, `/dist/tmp/widgets/node_modules/react-native-maps/node_modules/prop-types`))) { + throw new Error("Expected transitive node_modules to be copied, but it wasn't."); + } + logger(`Native dependency management succeeded!`); + } +} diff --git a/packages/command-tests/tsconfig.json b/packages/command-tests/tsconfig.json new file mode 100644 index 00000000..8a5c7ecc --- /dev/null +++ b/packages/command-tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "@tsconfig/node22/tsconfig.json", + "@tsconfig/node-ts/tsconfig.json" + ], + "compilerOptions": { +"allowImportingTsExtensions": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a93f9caf..4d6e89ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,70 +43,50 @@ importers: version: 8.59.3(eslint@10.4.0)(typescript@5.9.3) packages/command-tests: - devDependencies: + dependencies: '@mendix/generator-widget': specifier: workspace:* version: link:../generator-widget '@mendix/pluggable-widgets-tools': specifier: workspace:* version: link:../pluggable-widgets-tools - '@prettier/plugin-xml': - specifier: ^2.2.0 - version: 2.2.0 - '@types/jest-image-snapshot': - specifier: ^4.3.1 - version: 4.3.2 - '@types/node': - specifier: ^20.14.8 - version: 20.19.41 - '@types/shelljs': - specifier: ^0.10.0 - version: 0.10.0 - '@types/xml2js': - specifier: ^0.4.5 - version: 0.4.14 async-mutex: specifier: ^0.5.0 version: 0.5.0 - chalk: - specifier: ^5.6.2 - version: 5.6.2 - eslint-config-prettier: - specifier: ^8.5.0 - version: 8.10.2(eslint@10.4.0) fs-extra: specifier: ^11.3.4 version: 11.3.5 - globals: - specifier: ^17.6.0 - version: 17.6.0 - identity-obj-proxy: - specifier: ^3.0.0 - version: 3.0.0 - peggy: - specifier: ^1.2.0 - version: 1.2.0 shelljs: specifier: ^0.10.0 version: 0.10.0 - shx: - specifier: ^0.4.0 - version: 0.4.0 tree-kill: specifier: ^1.2.2 version: 1.2.2 - ts-node: - specifier: ^10.9.1 - version: 10.9.2(@swc/core@1.15.41)(@types/node@20.19.41)(typescript@4.9.5) - typescript: - specifier: ^4.8.4 - version: 4.9.5 yeoman-environment: specifier: ^6.0.1 version: 6.1.0(@types/node@20.19.41)(@yeoman/adapter@4.0.2(@types/node@20.19.41))(@yeoman/types@1.11.1(@types/node@20.19.41)(@yeoman/adapter@4.0.2(@types/node@20.19.41))(mem-fs@4.1.4))(mem-fs@4.1.4) yeoman-test: specifier: ^11.3.1 version: 11.5.2(@types/node@20.19.41)(@yeoman/adapter@4.0.2(@types/node@20.19.41))(@yeoman/types@1.11.1(@types/node@20.19.41)(@yeoman/adapter@4.0.2(@types/node@20.19.41))(mem-fs@4.1.4))(mem-fs@4.1.4)(yeoman-environment@6.1.0(@types/node@20.19.41)(@yeoman/adapter@4.0.2(@types/node@20.19.41))(@yeoman/types@1.11.1(@types/node@20.19.41)(@yeoman/adapter@4.0.2(@types/node@20.19.41))(mem-fs@4.1.4))(mem-fs@4.1.4))(yeoman-generator@8.2.2(@types/node@20.19.41)(@yeoman/types@1.11.1(@types/node@20.19.41)(@yeoman/adapter@4.0.2(@types/node@20.19.41))(mem-fs@4.1.4))(mem-fs@4.1.4)) + devDependencies: + '@tsconfig/node-ts': + specifier: ^23.6.4 + version: 23.6.4 + '@tsconfig/node22': + specifier: ^22.0.5 + version: 22.0.5 + '@types/fs-extra': + specifier: ^11.0.4 + version: 11.0.4 + '@types/node': + specifier: ^20.14.8 + version: 20.19.41 + '@types/shelljs': + specifier: ^0.10.0 + version: 0.10.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 packages/generator-widget: dependencies: @@ -1820,9 +1800,6 @@ packages: '@prettier/plugin-xml@1.2.0': resolution: {integrity: sha512-bFvVAZKs59XNmntYjyefn3K4TBykS6E+d6ZW8IcylAs88ZO+TzLhp0dPpi0VKfPzq1Nb+kpDnPRTiwb4zY6NgA==} - '@prettier/plugin-xml@2.2.0': - resolution: {integrity: sha512-UWRmygBsyj4bVXvDiqSccwT1kmsorcwQwaIy30yVh8T+Gspx4OlC0shX1y+ZuwXZvgnafmpRYKks0bAu9urJew==} - '@prettier/plugin-xml@3.4.2': resolution: {integrity: sha512-/UyNlHfkuLXG6Ed85KB0WBF283xn2yavR+UtRibBRUcvEJId2DSLdGXwJ/cDa1X++SWDPzq3+GSFniHjkNy7yg==} peerDependencies: @@ -2337,6 +2314,9 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tsconfig/node-ts@23.6.4': + resolution: {integrity: sha512-37BMJvNQZ+vTgd1xG2TGBkJ6ENeT4eO4Wh2CHrnn0IwH7ybLFCzh4Uc//kc7UIvqiRac4uGdIc1meKOjMSlKzw==} + '@tsconfig/node10@1.0.12': resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} @@ -2349,6 +2329,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tsconfig/node22@22.0.5': + resolution: {integrity: sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==} + '@tufjs/canonical-json@2.0.0': resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -2396,6 +2379,9 @@ packages: '@types/expect@1.20.4': resolution: {integrity: sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==} + '@types/fs-extra@11.0.4': + resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -2408,9 +2394,6 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - '@types/jest-image-snapshot@4.3.2': - resolution: {integrity: sha512-bJCAD1KO8bzdSmHfN+sGy6LIoHcNFA9/zRyzjQ+gJ5CZRcp9F9I5Sjl3/v1ndnyFpH22XCGVj+CUCteaXRwl3A==} - '@types/jest@30.0.0': resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} @@ -2420,6 +2403,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonfile@6.1.4': + resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} @@ -2447,9 +2433,6 @@ packages: '@types/picomatch@4.0.3': resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} - '@types/pixelmatch@5.2.6': - resolution: {integrity: sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==} - '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -5907,6 +5890,9 @@ packages: react-is@19.2.6: resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-native@0.78.3: resolution: {integrity: sha512-e8fMZ/hUHWest9VUaM7tz8AghfekwfSEbZOBrrN2dVt+wYvzEMWYPY3RopUf3M1UhKUdIlNBuCX0eQ8VDhdXGA==} engines: {node: '>=18'} @@ -6350,9 +6336,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - ssim.js@3.5.0: - resolution: {integrity: sha512-Aj6Jl2z6oDmgYFFbQqK7fght19bXdOxY7Tj03nF+03M9gCBAjeIiO8/PlEGMfKDwYpw4q6iBqVq2YuREorGg/g==} - ssri@13.0.1: resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -8910,11 +8893,6 @@ snapshots: '@xml-tools/parser': 1.0.11 prettier: 3.8.3 - '@prettier/plugin-xml@2.2.0': - dependencies: - '@xml-tools/parser': 1.0.11 - prettier: 3.8.3 - '@prettier/plugin-xml@3.4.2(prettier@3.8.3)': dependencies: '@xml-tools/parser': 1.0.11 @@ -9460,6 +9438,8 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tsconfig/node-ts@23.6.4': {} + '@tsconfig/node10@1.0.12': {} '@tsconfig/node12@1.0.11': {} @@ -9468,6 +9448,8 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@tsconfig/node22@22.0.5': {} + '@tufjs/canonical-json@2.0.0': {} '@tufjs/models@4.1.0': @@ -9519,6 +9501,11 @@ snapshots: '@types/expect@1.20.4': {} + '@types/fs-extra@11.0.4': + dependencies: + '@types/jsonfile': 6.1.4 + '@types/node': 16.18.126 + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 16.18.126 @@ -9533,12 +9520,6 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 - '@types/jest-image-snapshot@4.3.2': - dependencies: - '@types/jest': 30.0.0 - '@types/pixelmatch': 5.2.6 - ssim.js: 3.5.0 - '@types/jest@30.0.0': dependencies: expect: 30.4.1 @@ -9552,6 +9533,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonfile@6.1.4': + dependencies: + '@types/node': 16.18.126 + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.24 @@ -9579,10 +9564,6 @@ snapshots: '@types/picomatch@4.0.3': {} - '@types/pixelmatch@5.2.6': - dependencies: - '@types/node': 16.18.126 - '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -10840,10 +10821,6 @@ snapshots: dependencies: eslint: 10.4.0 - eslint-config-prettier@8.10.2(eslint@10.4.0): - dependencies: - eslint: 10.4.0 - eslint-config-prettier@8.10.2(eslint@9.39.4): dependencies: eslint: 9.39.4 @@ -13800,7 +13777,7 @@ snapshots: '@jest/schemas': 30.4.1 ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 - react-is-19: react-is@19.2.6 + react-is-19: react-is@19.2.8 pretty-ms@9.3.0: dependencies: @@ -13879,6 +13856,8 @@ snapshots: react-is@19.2.6: {} + react-is@19.2.8: {} + react-native@0.78.3(@babel/core@7.29.0)(@babel/preset-env@7.29.5(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6): dependencies: '@jest/create-cache-key-function': 29.7.0 @@ -14475,8 +14454,6 @@ snapshots: sprintf-js@1.0.3: {} - ssim.js@3.5.0: {} - ssri@13.0.1: dependencies: minipass: 7.1.3 @@ -14793,26 +14770,6 @@ snapshots: optionalDependencies: '@swc/core': 1.15.41 - ts-node@10.9.2(@swc/core@1.15.41)(@types/node@20.19.41)(typescript@4.9.5): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.19.41 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 4.9.5 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.15.41 - ts-node@10.9.2(@swc/core@1.15.41)(@types/node@20.19.41)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 From 57f2124843f69c028aa1a03ac21d49960f0f0833 Mon Sep 17 00:00:00 2001 From: Arjo Bruijnes Date: Tue, 28 Jul 2026 13:39:19 +0200 Subject: [PATCH 6/8] Add tagged template for applying ansi-styles --- packages/command-tests/lib/logger.ts | 16 ++++++++++++++++ packages/command-tests/main.ts | 27 ++++++++++----------------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/command-tests/lib/logger.ts b/packages/command-tests/lib/logger.ts index 737c2cd6..a5c24f8f 100644 --- a/packages/command-tests/lib/logger.ts +++ b/packages/command-tests/lib/logger.ts @@ -29,3 +29,19 @@ export function getWidgetLogger(index: number, ...[platform, boilerplate, lang, format(template, ...msgs) ); } + +/*** + * Template that applies the provided ansi-styles to the text. + * See `node:util.styleText` for more information. + * @example + * styled`Hi, ${"cyan"}Joe${"reset"}! You have ${"bold"}3 ${["bold", "yellow"]}pending${"reset"} messages.` + */ +export function styled(strings: TemplateStringsArray, ...styles: TextStyles[]) { + let result = ""; + let style: TextStyles = "reset"; + for (let i = 0; i < strings.length; i++) { + result += styleText(style, strings[i]); + style = styles[i] ?? "reset"; + } + return result; +} diff --git a/packages/command-tests/main.ts b/packages/command-tests/main.ts index 8969ae23..28ab43ad 100644 --- a/packages/command-tests/main.ts +++ b/packages/command-tests/main.ts @@ -7,12 +7,11 @@ import shelljs from "shelljs"; import { createInterface } from "readline/promises"; import { execAsync } from "./lib/exec.ts"; import { ensure, isDefined } from "./lib/option.ts"; -import { getWidgetLogger } from "./lib/logger.ts"; +import { getWidgetLogger, styled } from "./lib/logger.ts"; import { type Config, getWidgetName } from "./lib/config.ts"; import { repeat } from "./lib/strings.ts"; import { runTest } from "./tests/testRunner.ts"; import { ensureError } from "./lib/errors.ts"; -import { styleText } from "node:util"; const { readJson } = fsExtra; const { mkdir, rm, tempdir } = shelljs; @@ -91,13 +90,13 @@ async function main() { workDirs.push(workDir); return undefined; } catch (e) { - logger(styleText(["bold", "red"], "Stopped with error")); + logger(styled`${["bold", "red"]}Stopped with error`); const error = ensureError(e); error .toString() .split("\n") - .forEach(l => logger(styleText("red", l))); - logger(styleText(["bold", "red"], `Widget Directory ${workDir}`)); + .forEach(l => logger(styled`${"red"}%s`, l)); + logger(styled`${["bold", "red"]} Widget Directory %s`, workDir); return { config, error, workDir: workDir ?? "" }; } finally { release(); @@ -107,16 +106,12 @@ async function main() { ).filter(isDefined); console.log( - "\nFinished Testing: " + - styleText( - "bold", - styleText(failures.length > 0 ? "red" : "green", "%d Failed, ") + styleText("green", "%d Successful") - ), + styled`\nFinished Testing: ${["bold", failures.length > 0 ? "red" : "green"]}%d Failed, ${["bold", "green"]}%d Successful`, failures.length, CONFIGS.length - failures.length ); - console.log(styleText("green", "\nCreated %d temporary directories during testing"), workDirs.length); + console.log(styled`${"green"}\nCreated %d temporary directories during testing`, workDirs.length); let maxDirLength = 0, maxNameLength = 0; Object.entries(configWorkDirs) @@ -131,25 +126,23 @@ async function main() { " %s %s %s", dir + repeat(" ", maxDirLength - dir.length), name + repeat(" ", maxNameLength - name.length), - failures.some(f => f.workDir === dir) ? styleText(["bold", "red"], "❌ Error") : "" + failures.some(f => f.workDir === dir) ? styled`${["bold", "red"]}❌ Error` : "" ) ); if ( !readline.terminal || // If non-interactive, just clean up without asking. - /^y?e?s?$/i.test( - await readline.question(styleText("cyan", "Clean up test widgets? ") + styleText("gray", "(YES/no)")) - ) + /^y?e?s?$/i.test(await readline.question(styled`${"cyan"}Clean up test widgets? ${"gray"}(YES/no)`)) ) { console.log("Cleaning up temporary files"); try { rm("-rf", widgetsToolsPackagePath, ...workDirs); } catch (error) { - console.warn(styleText("yellow", `Unable to remove temporary files: ${ensureError(error).message}`)); + console.warn(styled`${"yellow"}Unable to remove temporary files: %s`, ensureError(error).message); } } else { console.log("Leaving temporary files"); } - console.log(styleText(["bold", "green"], "\nAll done!")); + console.log(styled`\n${["bold", "green"]}All done!`); } From e10f214cc2b861dfe40fa3fe54e5ad4c7b94e9ba Mon Sep 17 00:00:00 2001 From: Arjo Bruijnes Date: Tue, 28 Jul 2026 16:47:17 +0200 Subject: [PATCH 7/8] Differentiate better between different configurations --- packages/command-tests/lib/logger.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/command-tests/lib/logger.ts b/packages/command-tests/lib/logger.ts index a5c24f8f..c385b0c5 100644 --- a/packages/command-tests/lib/logger.ts +++ b/packages/command-tests/lib/logger.ts @@ -10,11 +10,11 @@ const COLORS: TextStyles[] = [ "blue", "magenta", "cyan", - "bgGreen", - "bgYellow", - "bgBlue", - "bgMagenta", - "bgCyan" + ["bold", "green"], + ["bold", "yellow"], + ["bold", "blue"], + ["bold", "magenta"], + ["bold", "cyan"] ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any From 1607779837a35b97896e365d107fad99d115483c Mon Sep 17 00:00:00 2001 From: Arjo Bruijnes Date: Tue, 28 Jul 2026 16:47:40 +0200 Subject: [PATCH 8/8] Add fallback to "reset" style when undefined --- packages/command-tests/lib/logger.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/command-tests/lib/logger.ts b/packages/command-tests/lib/logger.ts index c385b0c5..910fe175 100644 --- a/packages/command-tests/lib/logger.ts +++ b/packages/command-tests/lib/logger.ts @@ -41,7 +41,15 @@ export function styled(strings: TemplateStringsArray, ...styles: TextStyles[]) { let style: TextStyles = "reset"; for (let i = 0; i < strings.length; i++) { result += styleText(style, strings[i]); - style = styles[i] ?? "reset"; + const nextStyle = styles[i] ?? "reset"; + if ( + typeof nextStyle === "string" || + (typeof nextStyle === "object" && "every" in nextStyle && nextStyle.every(x => typeof x === "string")) + ) { + style = nextStyle; + } else { + throw Error(`Expected a TextStyle argument, but received "${nextStyle}"`); + } } return result; }