Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
423 changes: 0 additions & 423 deletions packages/command-tests/commands.js

This file was deleted.

10 changes: 10 additions & 0 deletions packages/command-tests/lib/config.ts
Original file line number Diff line number Diff line change
@@ -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}]`;
}
9 changes: 9 additions & 0 deletions packages/command-tests/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
26 changes: 26 additions & 0 deletions packages/command-tests/lib/exec.ts
Original file line number Diff line number Diff line change
@@ -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!`);
}
55 changes: 55 additions & 0 deletions packages/command-tests/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { format, styleText } from "node:util";
import { type Config, getWidgetName } from "./config.ts";

type Arguments<F> = F extends (...args: infer Args) => unknown ? Args : never;
type TextStyles = Arguments<typeof styleText>[0];

const COLORS: TextStyles[] = [
"green",
"yellow",
"blue",
"magenta",
"cyan",
["bold", "green"],
["bold", "yellow"],
["bold", "blue"],
["bold", "magenta"],
["bold", "cyan"]
];

// 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)
);
}

/***
* 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]);
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;
}
14 changes: 14 additions & 0 deletions packages/command-tests/lib/node.ts
Original file line number Diff line number Diff line change
@@ -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));
}
12 changes: 12 additions & 0 deletions packages/command-tests/lib/option.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type Option<T> = T | undefined;

export function ensure<T>(input: T | undefined): T {
if (isDefined(input)) {
return input;
}
throw Error("Unexpected value of undefined");
}

export function isDefined<T>(input: T | undefined): input is T {
return input !== undefined;
}
13 changes: 13 additions & 0 deletions packages/command-tests/lib/strings.ts
Original file line number Diff line number Diff line change
@@ -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;
}
148 changes: 148 additions & 0 deletions packages/command-tests/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#! /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, 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";

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<string, Config> = {};
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(styled`${["bold", "red"]}Stopped with error`);
const error = ensureError(e);
error
.toString()
.split("\n")
.forEach(l => logger(styled`${"red"}%s`, l));
logger(styled`${["bold", "red"]} Widget Directory %s`, workDir);
return { config, error, workDir: workDir ?? "<no-directory>" };
} finally {
release();
}
})
)
).filter(isDefined);

console.log(
styled`\nFinished Testing: ${["bold", failures.length > 0 ? "red" : "green"]}%d Failed, ${["bold", "green"]}%d Successful`,
failures.length,
CONFIGS.length - failures.length
);

console.log(styled`${"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) ? styled`${["bold", "red"]}❌ Error` : ""
)
);

if (
!readline.terminal || // If non-interactive, just clean up without asking.
/^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(styled`${"yellow"}Unable to remove temporary files: %s`, ensureError(error).message);
}
} else {
console.log("Leaving temporary files");
}

console.log(styled`\n${["bold", "green"]}All done!`);
}
31 changes: 15 additions & 16 deletions packages/command-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading