From 78b9246235935060c32768711979bdc66587ae0a Mon Sep 17 00:00:00 2001 From: leonardomendix Date: Tue, 2 Sep 2025 11:43:37 +0200 Subject: [PATCH 1/3] test: add Screenshot Updater CLI tool for updating widgets e2e screenshots --- automation/utils/README.md | 61 + .../utils/bin/rui-update-screenshots.ts | 1038 +++++++++++++++++ automation/utils/package.json | 7 + package.json | 2 + 4 files changed, 1108 insertions(+) create mode 100644 automation/utils/README.md create mode 100755 automation/utils/bin/rui-update-screenshots.ts diff --git a/automation/utils/README.md b/automation/utils/README.md new file mode 100644 index 0000000000..79db78376c --- /dev/null +++ b/automation/utils/README.md @@ -0,0 +1,61 @@ +# Screenshot Updater + +CLI tool for updating Playwright screenshots for Mendix widgets using Docker and Mendix runtime. + +## Quick Start + +```bash +# From project root (recommended) +pnpm run update-screenshots update + +# Or from automation/utils +cd automation/utils && pnpm run update-screenshots update +``` + +## Prerequisites + +- Docker running +- GitHub token (for Atlas updates): `export GITHUB_TOKEN=your_token` + +## Commands + +```bash +# List available widgets +pnpm run update-screenshots list + +# Update screenshots +pnpm run update-screenshots update calendar-web + +# Use specific Mendix version +pnpm run update-screenshots update calendar-web --mendix-version 10.24.0.73019 + +# Skip Atlas theme updates (faster) +pnpm run update-screenshots update calendar-web --skip-atlas-themesource + +# List available Mendix versions +pnpm run update-screenshots versions +``` + +## How It Works + +1. Downloads test project from GitHub +2. Builds widget and copies to test project +3. Updates Atlas components (if not skipped) +4. Creates Mendix deployment bundle +5. Starts Mendix runtime in Docker +6. Runs Playwright tests to update screenshots +7. Cleans up containers and temp files + +## Configuration + +| Environment Variable | Description | Default | +| -------------------- | ------------------------------------------- | -------------------------- | +| `GITHUB_TOKEN` | GitHub access token | Required for Atlas updates | +| `LOG_LEVEL` | Logging verbosity (`debug`, `info`, `warn`) | `info` | + +## Troubleshooting + +**Docker issues**: Ensure Docker is running (`docker info`) +**Widget not found**: Check available widgets with `list` command +**Build failures**: Enable debug logging (`LOG_LEVEL=debug`) +**Detailed logs**: Check `screenshot-updater.log` diff --git a/automation/utils/bin/rui-update-screenshots.ts b/automation/utils/bin/rui-update-screenshots.ts new file mode 100755 index 0000000000..f9ee636d87 --- /dev/null +++ b/automation/utils/bin/rui-update-screenshots.ts @@ -0,0 +1,1038 @@ +#!/usr/bin/env npx ts-node + +import { Command } from "commander"; +import { exec, execSync, spawn } from "child_process"; +import { promisify } from "util"; +import { createWriteStream, existsSync, promises as fs, readFileSync } from "fs"; +import * as path from "path"; +import * as os from "os"; +import { pipeline } from "stream/promises"; +import fetch from "node-fetch"; +import { createLogger, format, transports } from "winston"; +import * as crossZip from "cross-zip"; + +const execAsync = promisify(exec); + +// Configuration constants +const CONFIG = { + ATLAS: { + THEME_TAG: "atlasui-theme-files-2024-01-25", + CORE_TAG: "atlas-core-v3.18.1", + REPO_URL: "https://api.github.com/repos/mendix/atlas" + }, + DOCKER: { + PLAYWRIGHT_IMAGE: "mcr.microsoft.com/playwright:v1.51.1-jammy", + TIMEOUT_SECONDS: 60, + HEALTH_CHECK_INTERVAL: 2000 + }, + PATHS: { + MENDIX_VERSIONS: "automation/run-e2e/mendix-versions.json", + PLUGGABLE_WIDGETS: "packages/pluggableWidgets", + TESTS_DIR: "tests", + TEST_PROJECT: "tests/testProject" + }, + ATLAS_DIRS_TO_REMOVE: [ + "themesource/atlas_ui_resources", + "themesource/atlas_core", + "themesource/atlas_nativemobile_content", + "themesource/atlas_web_content", + "themesource/datawidgets" + ] +} as const; + +// Types +interface TestProject { + githubUrl: string; + branchName: string; +} + +interface WidgetPackage { + name: string; + version: string; + scripts?: { + [key: string]: string; + }; + testProject?: TestProject; +} + +interface MendixVersions { + latest: string; + [key: string]: string; +} + +interface GitHubAsset { + name: string; + url: string; +} + +interface GitHubRelease { + assets: GitHubAsset[]; +} + +interface UpdateOptions { + mendixVersion?: string; + skipAtlasThemesource: boolean; + githubToken?: string; +} + +// Logger setup +const logger = createLogger({ + level: process.env.LOG_LEVEL || "info", + format: format.combine( + format.timestamp(), + format.colorize(), + format.printf(({ timestamp, level, message }) => `${timestamp} [${level}]: ${message}`) + ), + transports: [new transports.Console(), new transports.File({ filename: "screenshot-updater.log", level: "debug" })] +}); + +// Custom errors +class SnapshotUpdaterError extends Error { + constructor( + message: string, + public readonly code?: string + ) { + super(message); + this.name = "SnapshotUpdaterError"; + } +} + +class ValidationError extends SnapshotUpdaterError { + constructor(message: string) { + super(message, "VALIDATION_ERROR"); + } +} + +class DockerError extends SnapshotUpdaterError { + constructor(message: string) { + super(message, "DOCKER_ERROR"); + } +} + +// Utility classes +class FileSystem { + static async ensureDir(dirPath: string): Promise { + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (error) { + throw new SnapshotUpdaterError(`Failed to create directory: ${dirPath}`, "FS_ERROR"); + } + } + + static async removeDir(dirPath: string): Promise { + if (existsSync(dirPath)) { + await fs.rm(dirPath, { recursive: true, force: true }); + } + } + + static async copyFile(src: string, dest: string): Promise { + await fs.copyFile(src, dest); + } + + static async readJsonFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf8"); + return JSON.parse(content); + } catch (error) { + throw new SnapshotUpdaterError(`Failed to read JSON file: ${filePath}`, "JSON_ERROR"); + } + } + + static async createTempDir(): Promise { + return await fs.mkdtemp(path.join(process.env.TMPDIR || "/tmp", "mx-e2e-")); + } +} + +class DockerManager { + static async isRunning(): Promise { + try { + await execAsync("docker info"); + return true; + } catch { + return false; + } + } + + static async findFreePort(): Promise { + try { + const { stdout } = await execAsync( + "node -e \"const net = require('net'); const server = net.createServer(); server.listen(0, () => { console.log(server.address().port); server.close(); });\"" + ); + return parseInt(stdout.trim(), 10); + } catch { + // Fallback to random port in ephemeral range + return Math.floor(Math.random() * (65535 - 49152) + 49152); + } + } + + static async runContainer(options: { + image: string; + name?: string; + ports?: Record; + volumes?: Record; + workdir?: string; + command?: string; + detached?: boolean; + environment?: Record; + }): Promise { + const args = ["run", "--rm"]; + + if (options.detached) args.push("-d"); + if (options.name) args.push("--name", options.name); + if (options.workdir) args.push("--workdir", options.workdir); + + if (options.ports) { + Object.entries(options.ports).forEach(([host, container]) => { + args.push("-p", `${host}:${container}`); + }); + } + + if (options.volumes) { + Object.entries(options.volumes).forEach(([host, container]) => { + args.push("-v", `${host}:${container}`); + }); + } + + if (options.environment) { + Object.entries(options.environment).forEach(([key, value]) => { + args.push("-e", `${key}=${value}`); + }); + } + + args.push(options.image); + if (options.command) args.push("bash", "-c", options.command); + + try { + const { stdout } = await execAsync(`docker ${args.join(" ")}`); + return stdout.trim(); + } catch (error) { + throw new DockerError(`Failed to run container: ${error}`); + } + } + + static async isContainerRunning(name: string): Promise { + try { + const { stdout } = await execAsync(`docker ps --filter name=${name} --format "{{.Names}}"`); + return stdout.trim() === name; + } catch { + return false; + } + } + + static async stopContainer(name: string): Promise { + try { + await execAsync(`docker rm -f ${name}`); + } catch { + // Container might not exist or already stopped + } + } + + static async getContainerLogs(name: string): Promise { + try { + const { stdout } = await execAsync(`docker logs ${name}`); + return stdout; + } catch { + return "No logs available"; + } + } +} + +class GitHubClient { + private readonly baseUrl = "https://api.github.com"; + private readonly headers: Record; + + constructor(private readonly token?: string) { + this.headers = { + "User-Agent": "mx-e2e-script", + Accept: "application/vnd.github+json" + }; + + if (token) { + this.headers["Authorization"] = `Bearer ${token}`; + } + } + + async downloadAsset(url: string, destPath: string): Promise { + const response = await fetch(url, { + headers: { + ...this.headers, + Accept: "application/octet-stream" + } + }); + + if (!response.ok) { + throw new SnapshotUpdaterError(`Failed to download asset: ${response.statusText}`); + } + + if (!response.body) { + throw new SnapshotUpdaterError("No response body"); + } + + const fileStream = createWriteStream(destPath); + await pipeline(response.body, fileStream); + } + + async getRelease(repo: string, tag: string): Promise { + const url = `${this.baseUrl}/repos/${repo}/releases/tags/${tag}`; + const response = await fetch(url, { headers: this.headers }); + + if (!response.ok) { + throw new SnapshotUpdaterError(`Failed to fetch release: ${response.statusText}`); + } + + return response.json() as Promise; + } +} + +class AtlasUpdater { + constructor( + private readonly githubClient: GitHubClient, + private readonly tempDir: string + ) {} + + async updateTheme(testProjectDir: string): Promise { + logger.info("Updating Atlas theme..."); + + try { + const release = await this.githubClient.getRelease("mendix/atlas", CONFIG.ATLAS.THEME_TAG); + const asset = release.assets.find(a => a.name.endsWith(".zip")); + + if (!asset) { + throw new SnapshotUpdaterError("No .zip asset in Atlas theme release"); + } + + const themeZip = path.join(this.tempDir, "AtlasTheme.zip"); + await this.githubClient.downloadAsset(asset.url, themeZip); + + const themeTarget = path.join(testProjectDir, "theme"); + await FileSystem.removeDir(themeTarget); + + // Extract and copy theme files + crossZip.unzipSync(themeZip, this.tempDir); + await this.copyThemeFiles(this.tempDir, themeTarget); + + logger.info("Atlas theme updated successfully"); + } catch (error) { + logger.warn(`Atlas theme update failed: ${error}`); + } + } + + async updateThemesource(testProjectDir: string): Promise { + logger.info("Updating Atlas themesource..."); + + try { + const release = await this.githubClient.getRelease("mendix/atlas", CONFIG.ATLAS.CORE_TAG); + const asset = release.assets.find(a => a.name.endsWith(".mpk")); + + if (!asset) { + throw new SnapshotUpdaterError("No .mpk asset in Atlas Core release"); + } + + // Remove old Atlas directories + await Promise.all( + CONFIG.ATLAS_DIRS_TO_REMOVE.map(dir => FileSystem.removeDir(path.join(testProjectDir, dir))) + ); + + const coreMpk = path.join(this.tempDir, "AtlasCore.mpk"); + await this.githubClient.downloadAsset(asset.url, coreMpk); + + // Extract themesource from MPK + crossZip.unzipSync(coreMpk, this.tempDir); + const themesourcePath = path.join(this.tempDir, "themesource"); + + if (!existsSync(themesourcePath)) { + throw new SnapshotUpdaterError("themesource directory not found in Atlas Core mpk"); + } + + // Copy themesource + const targetPath = path.join(testProjectDir, "themesource"); + await this.copyDirectory(themesourcePath, targetPath); + await execAsync(`chmod -R +w "${targetPath}"`); + + logger.info("Atlas themesource updated successfully"); + } catch (error) { + logger.warn(`Atlas themesource update failed: ${error}`); + } + } + + private async copyThemeFiles(sourceDir: string, targetDir: string): Promise { + await FileSystem.ensureDir(targetDir); + + const webSource = path.join(sourceDir, "web"); + const nativeSource = path.join(sourceDir, "native"); + + if (existsSync(webSource)) { + const webTarget = path.join(targetDir, "web"); + await this.copyDirectory(webSource, webTarget); + logger.info(`Copied web theme files to ${webTarget}`); + } + + if (existsSync(nativeSource)) { + const nativeTarget = path.join(targetDir, "native"); + await this.copyDirectory(nativeSource, nativeTarget); + logger.info(`Copied native theme files to ${nativeTarget}`); + } + + if (!existsSync(webSource) && !existsSync(nativeSource)) { + throw new SnapshotUpdaterError("No web or native theme directories found in Atlas theme zip"); + } + } + + private async copyDirectory(source: string, target: string): Promise { + await FileSystem.ensureDir(target); + const entries = await fs.readdir(source, { withFileTypes: true }); + + await Promise.all( + entries.map(async entry => { + const srcPath = path.join(source, entry.name); + const destPath = path.join(target, entry.name); + + if (entry.isDirectory()) { + await this.copyDirectory(srcPath, destPath); + } else { + await FileSystem.copyFile(srcPath, destPath); + } + }) + ); + } +} + +class WidgetValidator { + constructor(private readonly rootDir: string) {} + + async getAvailableWidgets(): Promise { + const widgetsDir = path.join(this.rootDir, CONFIG.PATHS.PLUGGABLE_WIDGETS); + + if (!existsSync(widgetsDir)) { + return []; + } + + const entries = await fs.readdir(widgetsDir, { withFileTypes: true }); + const widgets = entries.filter(entry => entry.isDirectory()).map(entry => entry.name); + + const validWidgets = await Promise.all( + widgets.map(async name => { + const isValid = await this.hasE2eTests(name); + return isValid ? name : null; + }) + ); + + return validWidgets.filter((name): name is string => name !== null); + } + + async validateWidget(widgetName: string): Promise { + if (!(await this.hasE2eTests(widgetName))) { + throw new ValidationError(`Widget '${widgetName}' does not have e2e tests.`); + } + } + + private async hasE2eTests(widgetName: string): Promise { + const e2eDir = path.join(this.rootDir, CONFIG.PATHS.PLUGGABLE_WIDGETS, widgetName, "e2e"); + + if (!existsSync(e2eDir)) { + return false; + } + + try { + const files = await fs.readdir(e2eDir); + return files.some(file => file.endsWith(".spec.js")); + } catch { + return false; + } + } +} + +class SnapshotUpdater { + private readonly rootDir: string; + private readonly mendixVersions: MendixVersions; + private readonly validator: WidgetValidator; + private readonly dockerManager = DockerManager; + private tempDir?: string; + private runtimeContainerId?: string; + + constructor() { + this.rootDir = path.resolve(__dirname, "../../.."); + this.validator = new WidgetValidator(this.rootDir); + + // Load Mendix versions + const versionsPath = path.join(this.rootDir, CONFIG.PATHS.MENDIX_VERSIONS); + this.mendixVersions = JSON.parse(readFileSync(versionsPath, "utf8")); + + // Setup cleanup on process termination + this.setupCleanup(); + } + + private setupCleanup(): void { + const cleanup = async () => { + if (this.tempDir) { + await FileSystem.removeDir(this.tempDir); + } + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + process.on("uncaughtException", cleanup); + process.on("unhandledRejection", cleanup); + } + + async updateSnapshots(widgetName: string, options: UpdateOptions): Promise { + try { + logger.info(`Starting screenshot update for widget: ${widgetName}`); + + // Validate prerequisites + await this.validatePrerequisites(widgetName); + + // Use provided Mendix version or default to latest + const version = options.mendixVersion || this.mendixVersions.latest; + logger.info(`Using Mendix version: ${version}`); + + // Create temporary directory + this.tempDir = await FileSystem.createTempDir(); + + // Set up test project + await this.setupTestProject(widgetName, options); + + // Build deployment bundle + await this.buildDeploymentBundle(widgetName, version); + + // Start Mendix runtime + const port = await this.startMendixRuntime(widgetName, version); + + // Wait for runtime to be ready + await this.waitForRuntime(port); + + // Run Playwright tests + await this.runPlaywrightTests(widgetName, port); + + logger.info(`Screenshots updated successfully for ${widgetName}`); + } catch (error) { + logger.error(`Failed to update screenshots: ${error}`); + throw error; + } finally { + await this.cleanup(); + } + } + + private async validatePrerequisites(widgetName: string): Promise { + // Check if Docker is running + if (!(await this.dockerManager.isRunning())) { + throw new DockerError("Docker is not running. Please start Docker and try again."); + } + + // Validate widget + await this.validator.validateWidget(widgetName); + } + + private async setupTestProject(widgetName: string, options: UpdateOptions): Promise { + logger.info(`Setting up test project for ${widgetName}...`); + + const widgetDir = path.join(this.rootDir, CONFIG.PATHS.PLUGGABLE_WIDGETS, widgetName); + const widgetPkg = await FileSystem.readJsonFile(path.join(widgetDir, "package.json")); + + if (!widgetPkg.testProject) { + throw new ValidationError("No testProject field in widget package.json"); + } + + // Download and extract test project + await this.downloadTestProject(widgetPkg.testProject); + + // Update Atlas components + if (options.githubToken) { + const githubClient = new GitHubClient(options.githubToken); + const atlasUpdater = new AtlasUpdater(githubClient, this.tempDir!); + + const testProjectDir = path.join(this.rootDir, CONFIG.PATHS.TEST_PROJECT); + await atlasUpdater.updateTheme(testProjectDir); + + if (!options.skipAtlasThemesource) { + await atlasUpdater.updateThemesource(testProjectDir); + } else { + logger.info("Skipping Atlas themesource update"); + } + } else { + logger.warn("No GitHub token provided, skipping Atlas updates"); + } + + // Build and copy widget + await this.buildAndCopyWidget(widgetName, widgetPkg.version); + } + + private async downloadTestProject(testProject: TestProject): Promise { + const url = `${testProject.githubUrl}/archive/refs/heads/${testProject.branchName}.zip`; + const zipPath = path.join(this.tempDir!, "testProject.zip"); + + logger.info(`Downloading test project from ${url}`); + + const response = await fetch(url); + if (!response.ok) { + throw new SnapshotUpdaterError(`Failed to download test project: ${response.statusText}`); + } + + const fileStream = createWriteStream(zipPath); + if (response.body) { + await pipeline(response.body, fileStream); + } + + // Extract and move + const testsDir = path.join(this.rootDir, CONFIG.PATHS.TESTS_DIR); + const testProjectDir = path.join(this.rootDir, CONFIG.PATHS.TEST_PROJECT); + + await FileSystem.removeDir(testProjectDir); + await FileSystem.ensureDir(testsDir); + + crossZip.unzipSync(zipPath, testsDir); + + const entries = await fs.readdir(testsDir); + const extracted = entries.find(f => f.startsWith("testProjects-")); + + if (!extracted) { + throw new SnapshotUpdaterError("Extracted test project dir not found"); + } + + await fs.rename(path.join(testsDir, extracted), testProjectDir); + + // Verify .mpr file exists + const projectFiles = await fs.readdir(testProjectDir); + if (!projectFiles.some(f => f.endsWith(".mpr"))) { + throw new SnapshotUpdaterError("No .mpr file in test project"); + } + } + + private async buildAndCopyWidget(widgetName: string, version: string): Promise { + logger.info(`Building and copying widget ${widgetName}...`); + + // First, build the widget using the release script + await this.buildWidget(widgetName); + + // Then copy the generated MPK files to the test project + await this.copyWidgetMpk(widgetName, version); + } + + private async buildWidget(widgetName: string): Promise { + logger.info(`Building widget ${widgetName}...`); + + const widgetDir = path.join(this.rootDir, CONFIG.PATHS.PLUGGABLE_WIDGETS, widgetName); + const widgetPkg = await FileSystem.readJsonFile(path.join(widgetDir, "package.json")); + + if (!widgetPkg || typeof widgetPkg.version !== "string") { + throw new SnapshotUpdaterError(`Invalid package.json in widget ${widgetName}`); + } + + // Check if widget has an e2e-update-project script first + if (widgetPkg.scripts?.["e2e-update-project"]) { + logger.info(`Running e2e-update-project script for ${widgetName}`); + + // Set environment variable for the script + process.env.MX_PROJECT_PATH = path.resolve(this.rootDir, CONFIG.PATHS.TEST_PROJECT); + + try { + await execAsync("pnpm run e2e-update-project", { + cwd: widgetDir, + env: { ...process.env } + }); + } catch (error) { + throw new SnapshotUpdaterError(`Failed to run e2e-update-project script: ${error}`); + } + } else { + // Fallback to standard release script + logger.info(`Running release script for ${widgetName}`); + + // Build the widget using pnpm run release with filter + // This ensures the widget is built with all its dependencies + try { + await execAsync(`pnpm run --workspace-root release --filter ${widgetPkg.name}`, { + cwd: this.rootDir, + env: { ...process.env } + }); + } catch (error) { + throw new SnapshotUpdaterError(`Failed to build widget ${widgetName}: ${error}`); + } + } + + logger.info(`Widget ${widgetName} built successfully`); + } + + private async copyWidgetMpk(widgetName: string, version: string): Promise { + const widgetDir = path.join(this.rootDir, CONFIG.PATHS.PLUGGABLE_WIDGETS, widgetName); + const mpkPattern = path.join(widgetDir, "dist", version, "*.mpk"); + const outDir = path.join(this.rootDir, CONFIG.PATHS.TEST_PROJECT, "widgets"); + + await FileSystem.ensureDir(outDir); + + // Find MPK files + const { stdout } = await execAsync(`ls ${mpkPattern}`); + const mpkFiles = stdout.trim().split("\n").filter(Boolean); + + if (mpkFiles.length === 0) { + throw new SnapshotUpdaterError(`No MPK files found in ${mpkPattern}`); + } + + // Copy MPK files + await Promise.all( + mpkFiles.map(mpkFile => FileSystem.copyFile(mpkFile, path.join(outDir, path.basename(mpkFile)))) + ); + } + + private async buildDeploymentBundle(widgetName: string, mendixVersion: string): Promise { + logger.info(`Building Mendix deployment bundle for ${widgetName}...`); + + const mxbuildImage = `mxbuild:${mendixVersion}`; + // Get the actual .mpr file path (resolve the glob) + const mprFiles = await fs.readdir(path.join(this.rootDir, "tests/testProject")); + const mprFile = mprFiles.find(file => file.endsWith(".mpr")); + + if (!mprFile) { + throw new SnapshotUpdaterError("No .mpr file found in test project"); + } + + const mprPath = `/source/tests/testProject/${mprFile}`; + + const subCommands = [ + `mx update-widgets --loose-version-check ${mprPath}`, + `mxbuild --output=/source/automation.mda ${mprPath}` + ]; + + const args = [ + `--tty`, + `--volume ${this.rootDir}:/source`, + `--rm`, + mxbuildImage, + `bash -c "${subCommands.join(" && ")}"` + ]; + + const command = [`docker run`, ...args].join(" "); + + try { + execSync(command, { stdio: "inherit" }); + } catch (error) { + throw new DockerError(`Failed to run container: ${error}`); + } + + const bundlePath = path.join(this.rootDir, "automation.mda"); + if (!existsSync(bundlePath)) { + throw new SnapshotUpdaterError("Deployment bundle (automation.mda) was not created. Build failed."); + } + + logger.info("Success. Bundle created and all the widgets are updated."); + } + + private async startMendixRuntime(widgetName: string, mendixVersion: string): Promise { + const port = await this.dockerManager.findFreePort(); + + logger.info(`Starting Mendix runtime for ${widgetName} on port ${port}...`); + + // Use the same approach as the working e2e implementation + const dockerDir = path.join(this.rootDir, "automation/run-e2e/docker"); + const labelPrefix = "e2e.mxruntime"; + const labelValue = Math.round(Math.random() * (999 - 100)) + 100; + const containerLabel = `${labelPrefix}=${labelValue}`; + + const args = [ + "run", + "--tty", + "--workdir", + "/source", + "--publish", + `${port}:8080`, + "--env", + `MENDIX_VERSION=${mendixVersion}`, + "--entrypoint", + "/bin/bash", + "--volume", + `${this.rootDir}:/source`, + "--volume", + `${dockerDir}:/shared:ro`, + "--label", + containerLabel, + `mxruntime:${mendixVersion}`, + "/shared/runtime.sh" + ]; + + spawn("docker", args, { stdio: "inherit" }); + + // Wait for container to get an ID + let runtimeContainerId = ""; + for (let attempts = 100; attempts > 0; --attempts) { + try { + const { stdout } = await execAsync(`docker ps --quiet --filter 'label=${containerLabel}'`); + runtimeContainerId = stdout.trim(); + if (runtimeContainerId) { + break; + } + } catch (error) { + // Continue waiting + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + if (!runtimeContainerId) { + throw new DockerError("Failed to get runtime container id. Probably container didn't start."); + } + + // Store the container ID for cleanup + this.runtimeContainerId = runtimeContainerId; + + return port; + } + + private async waitForRuntime(port: number): Promise { + logger.info(`Waiting for Mendix runtime to be ready on port ${port}...`); + + const ip = "localhost"; + let attempts = 60; + for (; attempts > 0; --attempts) { + try { + const response = await fetch(`http://${ip}:${port}`, { + method: "HEAD" + }); + if (response.ok) { + logger.info("Mendix runtime is ready"); + return; + } + } catch (error) { + logger.info(`Could not reach http://${ip}:${port}, trying again...`); + } + await new Promise(resolve => setTimeout(resolve, 3000)); + } + + if (attempts === 0) { + logger.error("Runtime didn't start"); + logger.error("Print runtime.log..."); + try { + const logContent = await fs.readFile(path.join(this.rootDir, "results/runtime.log"), "utf8"); + logger.error(logContent); + } catch (error) { + logger.error("Could not read runtime.log"); + } + throw new DockerError("Runtime didn't start in time, exiting now..."); + } + } + + private async runPlaywrightTests(widgetName: string, port: number): Promise { + logger.info(`Running Playwright screenshot update for ${widgetName}...`); + + // Get host IP address like the working e2e implementation + const nets = os.networkInterfaces(); + let hostIp = "localhost"; + + // Find the first non-internal IPv4 address + for (const name of Object.keys(nets)) { + const netArray = nets[name]; + if (netArray) { + for (const net of netArray) { + if (net.family === "IPv4" && !net.internal) { + hostIp = net.address; + break; + } + } + } + if (hostIp !== "localhost") break; + } + + // Create a temporary script file instead of using complex command escaping + const scriptPath = path.join(this.tempDir!, "playwright-script.sh"); + const scriptContent = `#!/bin/bash +set -e + +# Set environment variables for non-interactive mode +export SHELL=/bin/bash +export CI=true +export DEBIAN_FRONTEND=noninteractive + +# Install pnpm using npm (more reliable in containers) +echo "Installing pnpm..." +npm install -g pnpm@latest >/dev/null 2>&1 + +# Navigate to workspace root first to avoid workspace issues +cd /workspace + +# Navigate to widget directory +cd /workspace/packages/pluggableWidgets/${widgetName} + +echo "Installing Playwright browsers..." +# Install Playwright browsers with explicit flags +PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npx playwright install-deps --quiet >/dev/null 2>&1 || true +npx playwright install chromium --with-deps >/dev/null 2>&1 || { + echo "Playwright install failed, trying without deps..." + npx playwright install chromium >/dev/null 2>&1 +} + +# Test runtime connectivity +export URL="http://${hostIp}:${port}" +echo "Testing connection to Mendix runtime at $URL..." +if ! curl -s --head "$URL" >/dev/null 2>&1; then + echo "Warning: Could not connect to Mendix runtime at $URL" + echo "Available network interfaces:" + ip addr show 2>/dev/null | grep -E '^[0-9]+:|inet ' || ifconfig 2>/dev/null | grep -E 'inet ' +fi + +echo "Running Playwright screenshot tests..." +npx playwright test --update-snapshots --update-source-method=overwrite --project=chromium --reporter=list --workers=1 --timeout=30000 + +PLAYWRIGHT_EXIT_CODE=$? +echo "Playwright tests completed with exit code: $PLAYWRIGHT_EXIT_CODE" + +# Count and report snapshots +SNAPSHOT_COUNT=$(find e2e/ -name "*.png" 2>/dev/null | wc -l | tr -d ' ') +echo "Updated $SNAPSHOT_COUNT screenshot(s)" + +exit $PLAYWRIGHT_EXIT_CODE +`; + + await fs.writeFile(scriptPath, scriptContent, { mode: 0o755 }); + + // Use execSync with proper Docker network configuration + const playwrightImage = "mcr.microsoft.com/playwright:v1.51.1-jammy"; + + const dockerCommand = [ + "docker", + "run", + "--rm", + "--volume", + `${this.rootDir}:/workspace`, + "--volume", + `${scriptPath}:/tmp/playwright-script.sh`, + "--env", + `URL=http://${hostIp}:${port}`, + "--network", + "host", + playwrightImage, + "bash", + "/tmp/playwright-script.sh" + ]; + + logger.info(`Running Docker command: ${dockerCommand.join(" ")}`); + logger.info(`Script content written to: ${scriptPath}`); + + try { + execSync(dockerCommand.join(" "), { stdio: "inherit" }); + logger.info("Playwright screenshot update completed successfully"); + } catch (error) { + throw new DockerError(`Failed to run Playwright tests: ${error}`); + } finally { + // Cleanup the temporary script file + try { + await fs.unlink(scriptPath); + logger.debug(`Cleaned up temporary script file: ${scriptPath}`); + } catch (_error) { + // Ignore cleanup errors - file might not exist or be inaccessible + logger.debug(`Could not cleanup script file: ${scriptPath}`); + } + } + } + + private async cleanup(): Promise { + logger.info("Cleaning up..."); + + if (this.runtimeContainerId) { + try { + await execAsync(`docker rm -f ${this.runtimeContainerId}`); + } catch (_error) { + // Ignore cleanup errors + } + } + + if (this.tempDir) { + await FileSystem.removeDir(this.tempDir); + } + + const bundlePath = path.join(this.rootDir, "automation.mda"); + if (existsSync(bundlePath)) { + await fs.unlink(bundlePath); + } + } + + async getAvailableWidgets(): Promise { + return this.validator.getAvailableWidgets(); + } + + getAvailableVersions(): string[] { + return Object.keys(this.mendixVersions); + } + + async listWidgets(): Promise { + const widgets = await this.getAvailableWidgets(); + + if (widgets.length === 0) { + console.log("No widgets with e2e tests found."); + return; + } + + console.log("Available widgets with e2e tests:"); + widgets.forEach(widget => console.log(` - ${widget}`)); + } + + listVersions(): void { + console.log("Available Mendix versions:"); + Object.entries(this.mendixVersions).forEach(([key, version]) => { + console.log(` ${key}: ${version}`); + }); + } +} + +// CLI setup +const program = new Command(); + +program + .name("rui-update-screenshots") + .description("Update Playwright screenshots for a widget using Mendix runtime in Docker/Linux") + .version("2.0.0"); + +program + .command("list") + .description("List all available widgets with e2e tests") + .action(async () => { + try { + const updater = new SnapshotUpdater(); + await updater.listWidgets(); + } catch (error) { + logger.error(`Failed to list widgets: ${error}`); + process.exit(1); + } + }); + +program + .command("update") + .description("Update screenshots for a specific widget") + .argument("", "Name of the widget to update screenshots for") + .option("-v, --mendix-version ", "Mendix version to use (default: latest)") + .option("--skip-atlas-themesource", "Skip updating Atlas themesource", false) + .option("--github-token ", "GitHub token for authenticated downloads") + .action(async (widgetName: string, options) => { + try { + const githubToken = options.githubToken || process.env.GITHUB_TOKEN; + + if (!githubToken) { + logger.warn("No GitHub token provided. Atlas updates will be skipped."); + } + + const updater = new SnapshotUpdater(); + await updater.updateSnapshots(widgetName, { + mendixVersion: options.mendixVersion, + skipAtlasThemesource: options.skipAtlasThemesource, + githubToken + }); + } catch (error) { + logger.error(`Failed to update screenshots: ${error}`); + process.exit(1); + } + }); + +program + .command("versions") + .description("List available Mendix versions") + .action(() => { + try { + const updater = new SnapshotUpdater(); + updater.listVersions(); + } catch (error) { + logger.error(`Failed to list versions: ${error}`); + process.exit(1); + } + }); + +if (require.main === module) { + program.parse(); +} + +export { SnapshotUpdater }; diff --git a/automation/utils/package.json b/automation/utils/package.json index 480fe2dabe..2a718acc31 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -13,6 +13,7 @@ "rui-publish-marketplace": "bin/rui-publish-marketplace.ts", "rui-update-changelog-module": "bin/rui-update-changelog-module.ts", "rui-update-changelog-widget": "bin/rui-update-changelog-widget.ts", + "rui-update-screenshots": "bin/rui-update-screenshots.ts", "rui-verify-package-format": "bin/rui-verify-package-format.ts" }, "types": "index.ts", @@ -34,14 +35,19 @@ "prepare": "pnpm run compile:parser:widget && pnpm run compile:parser:module && tsc", "prepare-release": "ts-node bin/rui-prepare-release.ts", "start": "tsc --watch", + "update-screenshots": "ts-node bin/rui-update-screenshots.ts", "version": "ts-node bin/rui-bump-version.ts" }, "devDependencies": { "@mendix/eslint-config-web-widgets": "workspace:*", "@mendix/prettier-config-web-widgets": "workspace:*", + "@types/commander": "^2.12.5", "@types/cross-zip": "^4.0.2", "@types/node-fetch": "2.6.12", + "@types/shelljs": "^0.8.15", + "@types/winston": "^2.4.4", "chalk": "^5.4.1", + "commander": "^12.1.0", "cross-zip": "^4.0.1", "enquirer": "^2.4.1", "execa": "^5.1.1", @@ -52,6 +58,7 @@ "peggy": "^1.2.0", "shelljs": "^0.8.5", "ts-node": "^10.9.1", + "winston": "^3.17.0", "zod": "^3.25.67" } } diff --git a/package.json b/package.json index f9cc2c134d..e382c6ac95 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "test": "turbo run test --continue --concurrency 1", "verify": "turbo run verify --continue --concurrency 1", "version": "pnpm --filter @mendix/automation-utils run version" + "update-screenshots": "pnpm --filter @mendix/automation-utils run update-screenshots", + "postinstall": "turbo run agent-rules" }, "devDependencies": { "husky": "^8.0.3", From 74c72f6267c19a6dcbfa400626d72b748eef1034 Mon Sep 17 00:00:00 2001 From: leonardomendix Date: Tue, 2 Dec 2025 10:10:07 +0100 Subject: [PATCH 2/3] test: remove duplicated code --- .../utils/bin/rui-update-screenshots.ts | 98 +++++++++---------- 1 file changed, 47 insertions(+), 51 deletions(-) diff --git a/automation/utils/bin/rui-update-screenshots.ts b/automation/utils/bin/rui-update-screenshots.ts index f9ee636d87..c2739369e1 100755 --- a/automation/utils/bin/rui-update-screenshots.ts +++ b/automation/utils/bin/rui-update-screenshots.ts @@ -10,6 +10,8 @@ import { pipeline } from "stream/promises"; import fetch from "node-fetch"; import { createLogger, format, transports } from "winston"; import * as crossZip from "cross-zip"; +import { GitHub } from "../../utils/src/github"; +import { getPackageFileContent } from "../../utils/src/package-info"; const execAsync = promisify(exec); @@ -46,9 +48,9 @@ interface TestProject { branchName: string; } -interface WidgetPackage { - name: string; - version: string; +interface WidgetPackageJson { + name?: string; + version?: string; scripts?: { [key: string]: string; }; @@ -109,51 +111,42 @@ class DockerError extends SnapshotUpdaterError { } } -// Utility classes -class FileSystem { - static async ensureDir(dirPath: string): Promise { +// Utility functions +const FileSystem = { + async ensureDir(dirPath: string): Promise { try { await fs.mkdir(dirPath, { recursive: true }); - } catch (error) { + } catch (_error) { throw new SnapshotUpdaterError(`Failed to create directory: ${dirPath}`, "FS_ERROR"); } - } + }, - static async removeDir(dirPath: string): Promise { + async removeDir(dirPath: string): Promise { if (existsSync(dirPath)) { await fs.rm(dirPath, { recursive: true, force: true }); } - } + }, - static async copyFile(src: string, dest: string): Promise { + async copyFile(src: string, dest: string): Promise { await fs.copyFile(src, dest); - } - - static async readJsonFile(filePath: string): Promise { - try { - const content = await fs.readFile(filePath, "utf8"); - return JSON.parse(content); - } catch (error) { - throw new SnapshotUpdaterError(`Failed to read JSON file: ${filePath}`, "JSON_ERROR"); - } - } + }, - static async createTempDir(): Promise { - return await fs.mkdtemp(path.join(process.env.TMPDIR || "/tmp", "mx-e2e-")); + async createTempDir(): Promise { + return fs.mkdtemp(path.join(process.env.TMPDIR || "/tmp", "mx-e2e-")); } -} +}; -class DockerManager { - static async isRunning(): Promise { +const DockerManager = { + async isRunning(): Promise { try { await execAsync("docker info"); return true; } catch { return false; } - } + }, - static async findFreePort(): Promise { + async findFreePort(): Promise { try { const { stdout } = await execAsync( "node -e \"const net = require('net'); const server = net.createServer(); server.listen(0, () => { console.log(server.address().port); server.close(); });\"" @@ -163,9 +156,9 @@ class DockerManager { // Fallback to random port in ephemeral range return Math.floor(Math.random() * (65535 - 49152) + 49152); } - } + }, - static async runContainer(options: { + async runContainer(options: { image: string; name?: string; ports?: Record; @@ -205,29 +198,29 @@ class DockerManager { try { const { stdout } = await execAsync(`docker ${args.join(" ")}`); return stdout.trim(); - } catch (error) { - throw new DockerError(`Failed to run container: ${error}`); + } catch (_error) { + throw new DockerError(`Failed to run container: ${_error}`); } - } + }, - static async isContainerRunning(name: string): Promise { + async isContainerRunning(name: string): Promise { try { const { stdout } = await execAsync(`docker ps --filter name=${name} --format "{{.Names}}"`); return stdout.trim() === name; } catch { return false; } - } + }, - static async stopContainer(name: string): Promise { + async stopContainer(name: string): Promise { try { await execAsync(`docker rm -f ${name}`); } catch { // Container might not exist or already stopped } - } + }, - static async getContainerLogs(name: string): Promise { + async getContainerLogs(name: string): Promise { try { const { stdout } = await execAsync(`docker logs ${name}`); return stdout; @@ -235,20 +228,22 @@ class DockerManager { return "No logs available"; } } -} +}; -class GitHubClient { +class GitHubHelper extends GitHub { private readonly baseUrl = "https://api.github.com"; private readonly headers: Record; constructor(private readonly token?: string) { + super(); this.headers = { "User-Agent": "mx-e2e-script", - Accept: "application/vnd.github+json" + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" }; if (token) { - this.headers["Authorization"] = `Bearer ${token}`; + this.headers.Authorization = `Bearer ${token}`; } } @@ -286,7 +281,7 @@ class GitHubClient { class AtlasUpdater { constructor( - private readonly githubClient: GitHubClient, + private readonly githubClient: GitHubHelper, private readonly tempDir: string ) {} @@ -463,7 +458,7 @@ class SnapshotUpdater { } private setupCleanup(): void { - const cleanup = async () => { + const cleanup = async (): Promise => { if (this.tempDir) { await FileSystem.removeDir(this.tempDir); } @@ -527,7 +522,7 @@ class SnapshotUpdater { logger.info(`Setting up test project for ${widgetName}...`); const widgetDir = path.join(this.rootDir, CONFIG.PATHS.PLUGGABLE_WIDGETS, widgetName); - const widgetPkg = await FileSystem.readJsonFile(path.join(widgetDir, "package.json")); + const widgetPkg = (await getPackageFileContent(widgetDir)) as WidgetPackageJson; if (!widgetPkg.testProject) { throw new ValidationError("No testProject field in widget package.json"); @@ -538,7 +533,7 @@ class SnapshotUpdater { // Update Atlas components if (options.githubToken) { - const githubClient = new GitHubClient(options.githubToken); + const githubClient = new GitHubHelper(options.githubToken); const atlasUpdater = new AtlasUpdater(githubClient, this.tempDir!); const testProjectDir = path.join(this.rootDir, CONFIG.PATHS.TEST_PROJECT); @@ -554,7 +549,8 @@ class SnapshotUpdater { } // Build and copy widget - await this.buildAndCopyWidget(widgetName, widgetPkg.version); + const version = widgetPkg.version || "0.0.0"; + await this.buildAndCopyWidget(widgetName, version); } private async downloadTestProject(testProject: TestProject): Promise { @@ -612,7 +608,7 @@ class SnapshotUpdater { logger.info(`Building widget ${widgetName}...`); const widgetDir = path.join(this.rootDir, CONFIG.PATHS.PLUGGABLE_WIDGETS, widgetName); - const widgetPkg = await FileSystem.readJsonFile(path.join(widgetDir, "package.json")); + const widgetPkg = (await getPackageFileContent(widgetDir)) as WidgetPackageJson; if (!widgetPkg || typeof widgetPkg.version !== "string") { throw new SnapshotUpdaterError(`Invalid package.json in widget ${widgetName}`); @@ -759,7 +755,7 @@ class SnapshotUpdater { if (runtimeContainerId) { break; } - } catch (error) { + } catch (_error) { // Continue waiting } await new Promise(resolve => setTimeout(resolve, 100)); @@ -789,7 +785,7 @@ class SnapshotUpdater { logger.info("Mendix runtime is ready"); return; } - } catch (error) { + } catch (_error) { logger.info(`Could not reach http://${ip}:${port}, trying again...`); } await new Promise(resolve => setTimeout(resolve, 3000)); @@ -801,7 +797,7 @@ class SnapshotUpdater { try { const logContent = await fs.readFile(path.join(this.rootDir, "results/runtime.log"), "utf8"); logger.error(logContent); - } catch (error) { + } catch (_error) { logger.error("Could not read runtime.log"); } throw new DockerError("Runtime didn't start in time, exiting now..."); From 1a1e18c4aeef2a5c0dda0f47912819336f8523e2 Mon Sep 17 00:00:00 2001 From: leonardomendix Date: Tue, 2 Dec 2025 14:28:43 +0100 Subject: [PATCH 3/3] fix: merge conflicts --- package.json | 3 +- pnpm-lock.yaml | 188 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index e382c6ac95..592038e0e0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "oss-clearance": "pnpm --filter @mendix/automation-utils run oss-clearance", "create-gh-release": "turbo run create-gh-release --concurrency 1", "create-translation": "turbo run create-translation", - "postinstall": "turbo run agent-rules", "lint": "turbo run lint --continue --concurrency 1", "prepare": "husky install", "prepare-release": "pnpm --filter @mendix/automation-utils run prepare-release", @@ -20,7 +19,7 @@ "release": "turbo run release", "test": "turbo run test --continue --concurrency 1", "verify": "turbo run verify --continue --concurrency 1", - "version": "pnpm --filter @mendix/automation-utils run version" + "version": "pnpm --filter @mendix/automation-utils run version", "update-screenshots": "pnpm --filter @mendix/automation-utils run update-screenshots", "postinstall": "turbo run agent-rules" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 684f01279c..8c1f449a43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,15 +156,27 @@ importers: '@mendix/prettier-config-web-widgets': specifier: workspace:* version: link:../../packages/shared/prettier-config-web-widgets + '@types/commander': + specifier: ^2.12.5 + version: 2.12.5 '@types/cross-zip': specifier: ^4.0.2 version: 4.0.2 '@types/node-fetch': specifier: 2.6.12 version: 2.6.12 + '@types/shelljs': + specifier: ^0.8.15 + version: 0.8.17 + '@types/winston': + specifier: ^2.4.4 + version: 2.4.4 chalk: specifier: ^5.4.1 version: 5.6.2 + commander: + specifier: ^12.1.0 + version: 12.1.0 cross-zip: specifier: ^4.0.1 version: 4.0.1 @@ -195,6 +207,9 @@ importers: ts-node: specifier: 10.9.2 version: 10.9.2(@swc/core@1.13.5)(@types/node@22.14.1)(typescript@5.9.3) + winston: + specifier: ^3.17.0 + version: 3.18.3 zod: specifier: ^3.25.67 version: 3.25.76 @@ -3784,6 +3799,10 @@ packages: '@codemirror/view@6.38.6': resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==} + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + '@commitlint/cli@19.8.1': resolution: {integrity: sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==} engines: {node: '>=v18'} @@ -3857,6 +3876,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -4618,6 +4640,9 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + '@swc/core-darwin-arm64@1.13.5': resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} engines: {node: '>=10'} @@ -4784,6 +4809,10 @@ packages: '@types/cheerio@0.22.35': resolution: {integrity: sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==} + '@types/commander@2.12.5': + resolution: {integrity: sha512-YXGZ/rz+s57VbzcvEV9fUoXeJlBt5HaKu5iUheiIWNsJs23bz6AnRuRiZBRVBLYyPnixNvVnuzM5pSaxr8Yp/g==} + deprecated: This is a stub types definition. commander provides its own type definitions, so you do not need this installed. + '@types/conventional-commits-parser@5.0.1': resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==} @@ -4946,6 +4975,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/shelljs@0.8.17': + resolution: {integrity: sha512-IDksKYmQA2W9MkQjiyptbMmcQx+8+Ol6b7h6dPU5S05JyiQDSb/nZKnrMrZqGwgV6VkVdl6/SPCKPDlMRvqECg==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -4958,6 +4990,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -4967,6 +5002,10 @@ packages: '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/winston@2.4.4': + resolution: {integrity: sha512-BVGCztsypW8EYwJ+Hq+QNYiT/MUyCif0ouBH+flrY66O5W+KIXAMML6E/0fJpm7VjIzgangahl5S03bJJQGrZw==} + deprecated: This is a stub types definition. winston provides its own type definitions, so you do not need this installed. + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -5477,6 +5516,9 @@ packages: async@2.6.4: resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -5805,6 +5847,10 @@ packages: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + color-id@1.1.0: resolution: {integrity: sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==} @@ -5814,6 +5860,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + color-normalize@1.5.0: resolution: {integrity: sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==} @@ -5832,6 +5882,14 @@ packages: color-space@2.3.2: resolution: {integrity: sha512-BcKnbOEsOarCwyoLstcoEztwT0IJxqqQkNwDuA3a65sICvvHL2yoeV13psoDFh5IuiOMnIOKdQDwB4Mk3BypiA==} + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} @@ -6472,6 +6530,9 @@ packages: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} engines: {node: '>= 4'} + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -6926,6 +6987,9 @@ packages: picomatch: optional: true + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -6982,6 +7046,9 @@ packages: flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + font-atlas@2.1.0: resolution: {integrity: sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==} @@ -8045,6 +8112,9 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} @@ -8185,6 +8255,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -8681,6 +8755,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -9720,6 +9797,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -10198,6 +10279,9 @@ packages: resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} engines: {node: '>=8'} + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -10274,6 +10358,10 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -10684,6 +10772,14 @@ packages: wildcard@2.0.1: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.18.3: + resolution: {integrity: sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==} + engines: {node: '>= 12.0.0'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -11869,6 +11965,8 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 + '@colors/colors@1.6.0': {} + '@commitlint/cli@19.8.1(@types/node@22.14.1)(typescript@5.9.3)': dependencies: '@commitlint/format': 19.8.1 @@ -11983,6 +12081,12 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + '@discoveryjs/json-ext@0.5.7': {} '@eslint-community/eslint-utils@4.9.0(eslint@7.32.0)': @@ -12990,6 +13094,11 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + '@swc/core-darwin-arm64@1.13.5': optional: true @@ -13158,6 +13267,10 @@ snapshots: dependencies: '@types/node': 22.14.1 + '@types/commander@2.12.5': + dependencies: + commander: 12.1.0 + '@types/conventional-commits-parser@5.0.1': dependencies: '@types/node': 22.14.1 @@ -13350,6 +13463,11 @@ snapshots: '@types/semver@7.7.1': {} + '@types/shelljs@0.8.17': + dependencies: + '@types/node': 22.14.1 + glob: 11.0.3 + '@types/stack-utils@2.0.3': {} '@types/supercluster@7.1.3': @@ -13362,6 +13480,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/triple-beam@1.3.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -13369,6 +13489,10 @@ snapshots: '@types/whatwg-mimetype@3.0.2': {} + '@types/winston@2.4.4': + dependencies: + winston: 3.18.3 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -13999,6 +14123,8 @@ snapshots: dependencies: lodash: 4.17.21 + async@3.2.6: {} + asynckit@0.4.0: {} at-least-node@1.0.0: {} @@ -14394,6 +14520,10 @@ snapshots: dependencies: color-name: 1.1.4 + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + color-id@1.1.0: dependencies: clamp: 1.0.1 @@ -14402,6 +14532,8 @@ snapshots: color-name@1.1.4: {} + color-name@2.1.0: {} + color-normalize@1.5.0: dependencies: clamp: 1.0.1 @@ -14428,6 +14560,15 @@ snapshots: color-space@2.3.2: {} + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + colord@2.9.3: {} colorette@1.4.0: {} @@ -15099,6 +15240,8 @@ snapshots: emojis-list@3.0.0: {} + enabled@2.0.0: {} + encodeurl@1.0.2: {} encodeurl@2.0.0: {} @@ -15733,6 +15876,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fecha@4.2.3: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -15800,6 +15945,8 @@ snapshots: flow-enums-runtime@0.0.6: {} + fn.name@1.1.0: {} + font-atlas@2.1.0: dependencies: css-font: 1.2.0 @@ -17179,6 +17326,8 @@ snapshots: kleur@3.0.3: {} + kuler@2.0.0: {} + lazystream@1.0.1: dependencies: readable-stream: 2.3.8 @@ -17298,6 +17447,15 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -17979,6 +18137,10 @@ snapshots: dependencies: wrappy: 1.0.2 + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -19295,6 +19457,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sass-loader@13.3.3(sass@1.93.2)(webpack@5.102.1): @@ -19823,6 +19987,8 @@ snapshots: text-extensions@2.4.0: {} + text-hex@1.0.0: {} + text-table@0.2.0: {} throat@5.0.0: {} @@ -19893,6 +20059,8 @@ snapshots: tree-kill@1.2.2: {} + triple-beam@1.4.1: {} + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -20332,6 +20500,26 @@ snapshots: wildcard@2.0.1: {} + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.18.3: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.9 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + word-wrap@1.2.5: {} wordwrap@1.0.0: {}