UNPKG

tdpw

Version:

CLI tool for uploading Playwright test reports to TestDino platform with TestDino storage support

1,662 lines (1,652 loc) 233 kB
#!/usr/bin/env node 'use strict'; var commander = require('commander'); var chalk = require('chalk'); var ora = require('ora'); var cliProgress = require('cli-progress'); var figures = require('figures'); var fs = require('fs'); var path = require('path'); var zod = require('zod'); var simpleGit = require('simple-git'); var os2 = require('os'); var crypto = require('crypto'); var dotenv = require('dotenv-expand'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var chalk__default = /*#__PURE__*/_interopDefault(chalk); var ora__default = /*#__PURE__*/_interopDefault(ora); var cliProgress__default = /*#__PURE__*/_interopDefault(cliProgress); var figures__default = /*#__PURE__*/_interopDefault(figures); var path__default = /*#__PURE__*/_interopDefault(path); var simpleGit__default = /*#__PURE__*/_interopDefault(simpleGit); var os2__default = /*#__PURE__*/_interopDefault(os2); var crypto__default = /*#__PURE__*/_interopDefault(crypto); var dotenv__namespace = /*#__PURE__*/_interopNamespace(dotenv); var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) { if (typeof require !== "undefined") return require.apply(this, arguments); throw Error('Dynamic require of "' + x + '" is not supported'); }); function isInteractive() { if (process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI) { return false; } return Boolean(process.stdout.isTTY); } function supportsColor() { if (process.env.NO_COLOR) { return false; } if (process.env.FORCE_COLOR) { return true; } return Boolean(process.stdout.isTTY); } var colors = { // Status colors success: (text) => supportsColor() ? chalk__default.default.green(text) : text, error: (text) => supportsColor() ? chalk__default.default.red(text) : text, warning: (text) => supportsColor() ? chalk__default.default.yellow(text) : text, info: (text) => supportsColor() ? chalk__default.default.cyan(text) : text, // Text styles bold: (text) => supportsColor() ? chalk__default.default.bold(text) : text, dim: (text) => supportsColor() ? chalk__default.default.dim(text) : text, italic: (text) => supportsColor() ? chalk__default.default.italic(text) : text, underline: (text) => supportsColor() ? chalk__default.default.underline(text) : text, // Specific colors white: (text) => supportsColor() ? chalk__default.default.white(text) : text, gray: (text) => supportsColor() ? chalk__default.default.gray(text) : text, blue: (text) => supportsColor() ? chalk__default.default.blue(text) : text, magenta: (text) => supportsColor() ? chalk__default.default.magenta(text) : text, // Combined styles successBold: (text) => supportsColor() ? chalk__default.default.green.bold(text) : text, errorBold: (text) => supportsColor() ? chalk__default.default.red.bold(text) : text, url: (text) => supportsColor() ? chalk__default.default.blue.underline(text) : text, label: (text) => supportsColor() ? chalk__default.default.white.bold(text) : text, value: (text) => supportsColor() ? chalk__default.default.white(text) : text }; var symbols = { success: figures__default.default.tick, error: figures__default.default.cross, warning: figures__default.default.warning, info: figures__default.default.info, bullet: figures__default.default.bullet, pointer: figures__default.default.pointer, arrowRight: figures__default.default.arrowRight, line: figures__default.default.line, ellipsis: figures__default.default.ellipsis }; function box(content, options = {}) { const { padding = 1, borderColor = "white", title } = options; const maxContentWidth = Math.max( ...content.map((line) => stripAnsi(line).length), title ? stripAnsi(title).length : 0 ); const boxWidth = maxContentWidth + padding * 2 + 2; const chars = { topLeft: "\u250C", topRight: "\u2510", bottomLeft: "\u2514", bottomRight: "\u2518", horizontal: "\u2500", vertical: "\u2502" }; const colorFn = borderColor === "green" ? colors.success : borderColor === "red" ? colors.error : borderColor === "yellow" ? colors.warning : borderColor === "cyan" ? colors.info : colors.white; const lines = []; if (title) { const titleText = ` ${title} `; const remainingWidth = boxWidth - 2 - titleText.length; const leftPadding = Math.floor(remainingWidth / 2); const rightPadding = remainingWidth - leftPadding; lines.push( colorFn(chars.topLeft) + colorFn(chars.horizontal.repeat(leftPadding)) + colors.bold(titleText) + colorFn(chars.horizontal.repeat(rightPadding)) + colorFn(chars.topRight) ); } else { lines.push( colorFn(chars.topLeft) + colorFn(chars.horizontal.repeat(boxWidth - 2)) + colorFn(chars.topRight) ); } if (padding > 0) { lines.push( colorFn(chars.vertical) + " ".repeat(boxWidth - 2) + colorFn(chars.vertical) ); } for (const line of content) { const lineLength = stripAnsi(line).length; const rightPad = boxWidth - 2 - padding - lineLength - padding; lines.push( colorFn(chars.vertical) + " ".repeat(padding) + line + " ".repeat(Math.max(0, rightPad + padding)) + colorFn(chars.vertical) ); } if (padding > 0) { lines.push( colorFn(chars.vertical) + " ".repeat(boxWidth - 2) + colorFn(chars.vertical) ); } lines.push( colorFn(chars.bottomLeft) + colorFn(chars.horizontal.repeat(boxWidth - 2)) + colorFn(chars.bottomRight) ); return lines.join("\n"); } function stripAnsi(str) { return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ""); } var InteractiveSpinner = class { spinner; constructor(text) { this.spinner = ora__default.default({ text, spinner: "dots", color: "cyan" }); } get text() { return this.spinner.text; } set text(value) { this.spinner.text = value; } start(text) { this.spinner.start(text); } succeed(text) { this.spinner.succeed(text); } fail(text) { this.spinner.fail(text); } warn(text) { this.spinner.warn(text); } info(text) { this.spinner.info(text); } stop() { this.spinner.stop(); } }; var StaticSpinner = class { _text; constructor(text) { this._text = text; } get text() { return this._text; } set text(value) { this._text = value; } start(text) { console.log(` ${symbols.pointer} ${text || this._text}`); } succeed(text) { console.log(` ${colors.success(symbols.success)} ${text || this._text}`); } fail(text) { console.log(` ${colors.error(symbols.error)} ${text || this._text}`); } warn(text) { console.log(` ${colors.warning(symbols.warning)} ${text || this._text}`); } info(text) { console.log(` ${colors.info(symbols.info)} ${text || this._text}`); } stop() { } }; function createSpinner(text) { if (isInteractive()) { return new InteractiveSpinner(text); } return new StaticSpinner(text); } var InteractiveProgressBar = class { bar; constructor() { this.bar = new cliProgress__default.default.SingleBar( { format: " {bar} {percentage}% \u2502 {value}/{total} \u2502 {filename}", barCompleteChar: "\u2588", barIncompleteChar: "\u2591", hideCursor: true, clearOnComplete: true, barsize: 30 }, cliProgress__default.default.Presets.shades_classic ); } start(total, startValue = 0) { this.bar.start(total, startValue, { filename: "" }); } update(value, payload) { this.bar.update(value, payload); } increment(payload) { if (payload) { this.bar.increment(payload); } else { this.bar.increment(); } } stop() { this.bar.stop(); } }; var StaticProgressBar = class { total = 0; current = 0; start(total, startValue = 0) { this.total = total; this.current = startValue; } update(value, payload) { this.current = value; const filename = payload?.filename || ""; console.log( ` ${colors.success(symbols.success)} ${filename} (${value}/${this.total})` ); } increment(payload) { this.current++; const filename = payload?.filename || ""; console.log(` ${colors.success(symbols.success)} ${filename}`); } stop() { } }; function createProgressBar() { if (isInteractive()) { return new InteractiveProgressBar(); } return new StaticProgressBar(); } function formatKeyValue(key, value, keyWidth = 16) { const paddedKey = key.padEnd(keyWidth); return ` ${colors.label(paddedKey)} ${colors.value(String(value))}`; } function formatSection(title) { return ` ${colors.bold(title)}`; } function formatUrl(url) { return colors.url(url); } function printHeader(version, action = "Uploading Playwright reports") { console.log( box([`TestDino CLI v${version}`, colors.dim(action)], { borderColor: "cyan" }) ); console.log(); } function printSuccess(message, url) { const content = [colors.success(`${symbols.success} ${message}`)]; if (url) { content.push(""); content.push("View results:"); content.push(formatUrl(url)); } console.log(); console.log(box(content, { borderColor: "green" })); } function printError(title, message, suggestions) { const content = [colors.error(`${symbols.error} ${title}`)]; content.push(""); content.push(message); if (suggestions && suggestions.length > 0) { content.push(""); content.push(colors.dim("Troubleshooting:")); for (const suggestion of suggestions) { content.push(`${colors.dim(symbols.bullet)} ${suggestion}`); } } console.log(); console.log(box(content, { borderColor: "red" })); } function printHelpHeader(version, description) { console.log( box([`TestDino CLI v${version}`, colors.dim(description)], { borderColor: "cyan", padding: 1 }) ); } function printHelpSection(title) { console.log(` ${colors.bold(title)}`); } function printHelpCommand(name, description, nameWidth = 20) { console.log( ` ${colors.info(name.padEnd(nameWidth))} ${colors.dim(description)}` ); } function printHelpOption(flags, description, flagWidth = 28) { console.log(` ${colors.gray(flags.padEnd(flagWidth))} ${description}`); } function printHelpExample(command, description) { console.log(` ${colors.white("$")} ${command}`); } function printHelpEnvVar(name, description, nameWidth = 22) { console.log( ` ${colors.gray(name.padEnd(nameWidth))} ${colors.dim(description)}` ); } // src/utils/logger.ts var verboseMode = false; function setVerbose(verbose) { verboseMode = verbose; } function redact(value) { return value.replace(/trx_[a-z]+_[a-f0-9]{64}/gi, "trx_***").replace(/\?sv=[^&\s]+(&[^&\s]+)*/g, "?[REDACTED]").replace(/sig=[^&\s]+/g, "sig=***"); } var logger = { info: (msg) => console.log(` ${msg}`), success: (msg) => console.log(` ${colors.success(symbols.success)} ${msg}`), warn: (msg) => console.warn(` ${colors.warning(symbols.warning)} ${colors.warning(msg)}`), error: (msg) => console.error(` ${colors.error(symbols.error)} ${colors.error(msg)}`), verbose: (msg) => verboseMode && console.log(` ${colors.dim(redact(msg))}`), debug: (msg) => verboseMode && console.log(` ${colors.dim(`[DEBUG] ${redact(msg)}`)}`), section: (title) => console.log(formatSection(title)), kv: (key, value) => console.log(formatKeyValue(key, value)), item: (msg) => console.log(` ${colors.dim(symbols.bullet)} ${msg}`), blank: () => console.log(), redact }; // src/utils/progress.ts var SpinnerProgressTracker = class { spinner = null; start(message) { this.spinner = createSpinner(message); this.spinner.start(); } update(message) { if (this.spinner) { this.spinner.text = message; } } succeed(message) { if (this.spinner) { this.spinner.succeed(message); this.spinner = null; } } fail(message) { if (this.spinner) { this.spinner.fail(message); this.spinner = null; } } warn(message) { if (this.spinner) { this.spinner.warn(message); this.spinner = null; } } }; function createProgressTracker() { return new SpinnerProgressTracker(); } zod.z.object({ reportDirectory: zod.z.string().min(1, "Report directory is required"), token: zod.z.string().min(1, "API token is required"), uploadImages: zod.z.boolean().optional().default(false), uploadVideos: zod.z.boolean().optional().default(false), uploadHtml: zod.z.boolean().optional().default(false), uploadTraces: zod.z.boolean().optional().default(false), uploadFiles: zod.z.boolean().optional().default(false), uploadFullJson: zod.z.boolean().optional().default(false), jsonReport: zod.z.string().optional(), htmlReport: zod.z.string().optional(), traceDir: zod.z.string().optional(), verbose: zod.z.boolean().optional().default(false), json: zod.z.boolean().optional().default(false), environment: zod.z.string().optional().default("unknown"), tags: zod.z.array(zod.z.string()).optional().default([]) }); var ConfigSchema = zod.z.object({ apiUrl: zod.z.string().url(), token: zod.z.string().min(1), uploadImages: zod.z.boolean().default(false), uploadVideos: zod.z.boolean().default(false), uploadHtml: zod.z.boolean().default(false), uploadTraces: zod.z.boolean().default(false), uploadFiles: zod.z.boolean().default(false), uploadFullJson: zod.z.boolean().default(false), verbose: zod.z.boolean().default(false), // Target environment tag for upload metadata environment: zod.z.string().default("unknown"), // Run tags (custom tags passed via --tag option) tags: zod.z.array(zod.z.string()).default([]), // Performance and upload settings batchSize: zod.z.number().min(1).max(20).default(5), maxConcurrentUploads: zod.z.number().min(1).max(50).default(10), uploadTimeout: zod.z.number().min(5e3).max(3e5).default(6e4), retryAttempts: zod.z.number().min(1).max(10).default(3) }); var BaseError = class extends Error { code; cause; constructor(message, code, cause) { super(message); this.name = this.constructor.name; this.code = code; if (cause !== void 0) { this.cause = cause; } Error.captureStackTrace(this, this.constructor); } }; var ConfigurationError = class extends BaseError { constructor(message, cause) { super(message, "CONFIG_ERROR", cause); } }; var ValidationError = class extends BaseError { constructor(message, cause) { super(message, "VALIDATION_ERROR", cause); } }; var NetworkError = class extends BaseError { constructor(message, cause) { super(message, "NETWORK_ERROR", cause); } }; var FileSystemError = class extends BaseError { constructor(message, cause) { super(message, "FILESYSTEM_ERROR", cause); } }; var AuthenticationError = class extends BaseError { constructor(message, cause) { super(message, "AUTH_ERROR", cause); } }; var UsageLimitError = class extends BaseError { data; constructor(message, data, cause) { super(message, "QUOTA_LIMIT_EXCEEDED", cause); this.data = data; } }; var stringToBoolean = (value) => { if (!value) return false; return ["true", "1", "yes", "on"].includes(value.toLowerCase()); }; var ValidationUtils = class { /** * Validate data against a Zod schema with user-friendly error messages */ static validateSchema(schema, data, context) { try { const validated = schema.parse(data); return { success: true, data: validated }; } catch (error) { if (error instanceof zod.z.ZodError) { const errors = error.issues.map((issue) => { const path2 = issue.path.length > 0 ? issue.path.join(".") : "root"; return `${path2}: ${issue.message}`; }); return { success: false, errors }; } return { success: false, errors: [`Unexpected validation error in ${context}: ${String(error)}`] }; } } /** * Validate and throw appropriate error with context */ static validateOrThrow(schema, data, context) { const result = this.validateSchema(schema, data, context); if (!result.success) { throw new ValidationError( `Validation failed for ${context}: ${result.errors?.join(", ")}` ); } return result.data; } /** * Validate API token format with detailed feedback */ static validateApiToken(token) { if (!token) { throw new ValidationError("API token is required"); } if (token.length < 10) { throw new ValidationError("API token is too short"); } const tokenPattern = /^trx_(development|staging|production)_[a-f0-9]{64}$/; if (!tokenPattern.test(token)) { const parts = token.split("_"); if (parts.length !== 3) { throw new ValidationError( "API token must have 3 parts separated by underscores: trx_{environment}_{key}" ); } const prefix = parts[0]; const environment = parts[1]; const key = parts[2]; if (prefix !== "trx") { throw new ValidationError( `API token must start with "trx", found "${prefix}"` ); } if (!environment || !["development", "staging", "production"].includes(environment)) { throw new ValidationError( `Invalid environment "${environment || "empty"}". Must be: development, staging, or production` ); } if (!key || !/^[a-f0-9]{64}$/.test(key)) { throw new ValidationError( "API token key must be 64 lowercase hexadecimal characters" ); } } } /** * Validate URL format with helpful feedback */ static validateUrl(url, context) { if (!url) { throw new ValidationError(`${context} URL is required`); } try { const parsed = new URL(url); if (!["http:", "https:"].includes(parsed.protocol)) { throw new ValidationError( `${context} URL must use HTTP or HTTPS protocol, found: ${parsed.protocol}` ); } if (!parsed.hostname) { throw new ValidationError(`${context} URL must have a valid hostname`); } } catch (error) { if (error instanceof ValidationError) { throw error; } throw new ValidationError(`Invalid ${context} URL format: ${url}`); } } /** * Validate file path exists and is accessible */ static validateFilePath(path2, context) { if (!path2) { throw new ValidationError(`${context} path is required`); } if (path2.includes("..")) { throw new ValidationError( `${context} path cannot contain parent directory references (..)` ); } if (path2.startsWith("/") && !process.platform.startsWith("win")) { logger.warn(`Using absolute path for ${context}: ${path2}`); } } /** * Validate Node.js version requirement */ static validateNodeVersion(requiredVersion = "18.0.0") { const currentVersion = process.version; const current = this.parseVersion(currentVersion.slice(1)); const required = this.parseVersion(requiredVersion); if (this.compareVersions(current, required) < 0) { throw new ConfigurationError( `Node.js ${requiredVersion} or higher is required. Current version: ${currentVersion}` ); } } /** * Parse semantic version string */ static parseVersion(version) { const parts = version.split(".").map(Number); return [parts[0] || 0, parts[1] || 0, parts[2] || 0]; } /** * Compare two semantic versions * Returns: -1 if a < b, 0 if a === b, 1 if a > b */ static compareVersions(a, b) { for (let i = 0; i < 3; i++) { const aVal = a[i] ?? 0; const bVal = b[i] ?? 0; if (aVal < bVal) return -1; if (aVal > bVal) return 1; } return 0; } /** * Validate environment variable name */ static validateEnvVarName(name) { if (!name) { throw new ValidationError("Environment variable name is required"); } if (!/^[A-Z][A-Z0-9_]*$/.test(name)) { throw new ValidationError( `Environment variable name "${name}" must be uppercase letters, numbers, and underscores only` ); } } /** * Sanitize and validate file size */ static validateFileSize(size, maxSize, filename) { if (size < 0) { throw new ValidationError(`Invalid file size for ${filename}: ${size}`); } if (size > maxSize) { const sizeMB = Math.round(size / 1024 / 1024); const maxSizeMB = Math.round(maxSize / 1024 / 1024); throw new ValidationError( `File ${filename} is too large: ${sizeMB}MB (max: ${maxSizeMB}MB)` ); } } /** * Validate timeout value */ static validateTimeout(timeout) { if (timeout <= 0) { throw new ValidationError("Timeout must be greater than 0"); } if (timeout > 3e5) { logger.warn(`Large timeout value: ${timeout}ms (${timeout / 1e3}s)`); } } /** * Validate retry count */ static validateRetryCount(retries) { if (retries < 0) { throw new ValidationError("Retry count cannot be negative"); } if (retries > 10) { logger.warn(`High retry count: ${retries} (this may cause long delays)`); } } }; // src/utils/fs.ts function resolvePath(p, context) { ValidationUtils.validateFilePath(p, context); return path.resolve(p); } async function exists(p) { try { await fs.promises.stat(p); return true; } catch { return false; } } async function isDirectory(p) { try { const stats = await fs.promises.stat(p); return stats.isDirectory(); } catch (_error) { return false; } } async function isFile(p) { try { const stats = await fs.promises.stat(p); return stats.isFile(); } catch (_error) { return false; } } async function readDir(p) { try { const entries = await fs.promises.readdir(p); return entries.map((name) => path.join(p, name)); } catch (error) { throw new FileSystemError(`Failed to read directory: ${p}`, error); } } async function readFile(p) { try { return await fs.promises.readFile(p, "utf-8"); } catch (error) { throw new FileSystemError(`Failed to read file: ${p}`, error); } } async function readFileBuffer(p) { try { return await fs.promises.readFile(p); } catch (error) { throw new FileSystemError( `Failed to read file buffer: ${p}`, error ); } } var PlaywrightShardDetector = class { /** * Auto-detect shard information from Playwright configuration and environment */ static async detectShardInfo(workingDir) { try { const envShard = this.detectFromEnvironment(); if (envShard) { return envShard; } const configShard = await this.detectFromPlaywrightConfig(workingDir); if (configShard) { return configShard; } const reportShard = await this.detectFromReports(workingDir); if (reportShard) { return reportShard; } return null; } catch (_error) { return null; } } /** * Detect shard info from environment variables */ static detectFromEnvironment() { try { const env = process.env; if (env.GITHUB_ACTIONS && env.SHARD_INDEX && env.SHARD_TOTAL) { const shardIndex = parseInt(env.SHARD_INDEX, 10); const shardTotal = parseInt(env.SHARD_TOTAL, 10); if (shardIndex > 0 && shardTotal > 0) { return { shardIndex, shardTotal, shardId: `${shardIndex}/${shardTotal}` }; } } if (env.GITLAB_CI && env.CI_NODE_INDEX && env.CI_NODE_TOTAL) { const shardIndex = parseInt(env.CI_NODE_INDEX, 10) + 1; const shardTotal = parseInt(env.CI_NODE_TOTAL, 10); if (shardIndex > 0 && shardTotal > 0) { return { shardIndex, shardTotal, shardId: `${shardIndex}/${shardTotal}` }; } } if (env.CIRCLECI && env.CIRCLE_NODE_INDEX && env.CIRCLE_NODE_TOTAL) { const shardIndex = parseInt(env.CIRCLE_NODE_INDEX, 10) + 1; const shardTotal = parseInt(env.CIRCLE_NODE_TOTAL, 10); if (shardIndex > 0 && shardTotal > 0) { return { shardIndex, shardTotal, shardId: `${shardIndex}/${shardTotal}` }; } } if (env.SHARD_INDEX && env.SHARD_TOTAL) { const shardIndex = parseInt(env.SHARD_INDEX, 10); const shardTotal = parseInt(env.SHARD_TOTAL, 10); if (shardIndex > 0 && shardTotal > 0) { return { shardIndex, shardTotal, shardId: `${shardIndex}/${shardTotal}` }; } } return null; } catch { return null; } } /** * Detect shard info from Playwright configuration files */ static async detectFromPlaywrightConfig(workingDir) { const configFiles = [ "playwright.config.ts", "playwright.config.js", "playwright.config.mjs", "playwright.config.cjs" ]; for (const configFile of configFiles) { const configPath = path__default.default.join(workingDir, configFile); if (await exists(configPath)) { try { const configContent = await readFileBuffer(configPath); const configText = configContent.toString(); const shardMatch = configText.match(/shard:\s*(\d+)\s*\/\s*(\d+)/); if (shardMatch?.[1] && shardMatch[2]) { const shardIndex = parseInt(shardMatch[1], 10); const shardTotal = parseInt(shardMatch[2], 10); return { shardIndex, shardTotal, shardId: `${shardIndex}/${shardTotal}` }; } const cliShardMatch = configText.match(/--shard[=\s]+(\d+)\/(\d+)/); if (cliShardMatch?.[1] && cliShardMatch[2]) { const shardIndex = parseInt(cliShardMatch[1], 10); const shardTotal = parseInt(cliShardMatch[2], 10); return { shardIndex, shardTotal, shardId: `${shardIndex}/${shardTotal}` }; } } catch { continue; } } } return null; } /** * Detect shard info from JSON reports (look for metadata in reports) */ static async detectFromReports(workingDir) { try { const reportPaths = [ path__default.default.join(workingDir, "playwright-report", "report.json"), path__default.default.join(workingDir, "playwright-report", "results.json"), path__default.default.join(workingDir, "test-results", "results.json"), path__default.default.join(workingDir, "test-results", "report.json"), path__default.default.join(workingDir, "results.json"), path__default.default.join(workingDir, "report.json") ]; for (const reportPath of reportPaths) { if (await exists(reportPath)) { try { const reportContent = await readFileBuffer(reportPath); const reportText = reportContent.toString(); try { const reportJson = JSON.parse(reportText); if (reportJson?.config?.shard && typeof reportJson.config.shard === "object") { const shard = reportJson.config.shard; if (typeof shard.current === "number" && typeof shard.total === "number") { const shardIndex = shard.current; const shardTotal = shard.total; if (shardIndex > 0 && shardTotal > 0 && shardIndex <= shardTotal) { return { shardIndex, shardTotal, shardId: `${shardIndex}/${shardTotal}` }; } } } } catch { } const shardMatch = reportText.match(/"shard":\s*"(\d+)\/(\d+)"/); if (shardMatch?.[1] && shardMatch[2]) { const shardIndex = parseInt(shardMatch[1], 10); const shardTotal = parseInt(shardMatch[2], 10); return { shardIndex, shardTotal, shardId: `${shardIndex}/${shardTotal}` }; } if (reportText.includes("--shard")) { const cliMatch = reportText.match(/--shard[=\s]+(\d+)\/(\d+)/); if (cliMatch?.[1] && cliMatch[2]) { const shardIndex = parseInt(cliMatch[1], 10); const shardTotal = parseInt(cliMatch[2], 10); return { shardIndex, shardTotal, shardId: `${shardIndex}/${shardTotal}` }; } } } catch { continue; } } } return null; } catch { return null; } } /** * Validate shard information */ static validateShardInfo(shardInfo) { return shardInfo.shardIndex > 0 && shardInfo.shardTotal > 0 && shardInfo.shardIndex <= shardInfo.shardTotal && shardInfo.shardId === `${shardInfo.shardIndex}/${shardInfo.shardTotal}`; } /** * Create default shard info (single shard) */ static createDefaultShardInfo() { return { shardIndex: 1, shardTotal: 1, shardId: "1/1" }; } }; var PlaywrightReportSchema = zod.z.object({ config: zod.z.any(), suites: zod.z.array(zod.z.any()), stats: zod.z.object({ startTime: zod.z.string(), duration: zod.z.number(), expected: zod.z.number(), skipped: zod.z.number(), unexpected: zod.z.number(), flaky: zod.z.number() }), errors: zod.z.array(zod.z.any()).optional(), metadata: zod.z.any().optional() }); async function parsePlaywrightJson(jsonPath) { let raw; try { raw = await readFile(jsonPath); } catch (error) { throw new ValidationError( `Failed to read JSON report at "${jsonPath}": ${error.message}` ); } let parsed; try { parsed = JSON.parse(raw); } catch (error) { throw new ValidationError( `Invalid JSON format in report: ${error.message}` ); } const result = PlaywrightReportSchema.safeParse(parsed); if (!result.success) { const issues = result.error.issues.map( (issue) => `${issue.path.join(".")}: ${issue.message}` ); throw new ValidationError( `Playwright report validation failed: ${issues.join(", ")}` ); } return result.data; } async function validateJsonReport(jsonPath) { try { const stat = await fs.promises.stat(jsonPath); if (!stat.isFile()) { throw new ValidationError(`JSON report path is not a file: ${jsonPath}`); } } catch (error) { if (error instanceof ValidationError) throw error; throw new FileSystemError( `JSON report not found: ${jsonPath}`, error ); } try { const content = await fs.promises.readFile(jsonPath, "utf-8"); JSON.parse(content); } catch (error) { if (error instanceof SyntaxError) { throw new ValidationError( `Failed to parse JSON report: ${error.message}` ); } throw error; } } async function validateHtmlReportDir(htmlDir) { try { const stat = await fs.promises.stat(htmlDir); if (!stat.isDirectory()) { throw new ValidationError( `HTML report path is not a directory: ${htmlDir}` ); } } catch (error) { throw new FileSystemError( `HTML report directory not found: ${htmlDir}`, error ); } const indexPath = path.join(htmlDir, "index.html"); try { const stat = await fs.promises.stat(indexPath); if (!stat.isFile()) { throw new ValidationError( `index.html not found in HTML report directory: ${htmlDir}` ); } } catch { throw new ValidationError( `index.html not found in HTML report directory: ${htmlDir}` ); } } async function validateTraceDir(traceDir) { try { const stat = await fs.promises.stat(traceDir); if (!stat.isDirectory()) { throw new ValidationError(`Trace path is not a directory: ${traceDir}`); } } catch (error) { throw new FileSystemError( `Trace directory not found: ${traceDir}`, error ); } } // src/core/discovery.ts var ReportDiscoveryService = class { constructor(reportDir) { this.reportDir = reportDir; } reportDir; /** * Discover report files based on CLI options with smart scanning. */ async discover(options) { const baseDir = resolvePath(options.reportDirectory, "Report directory"); const jsonReportPath = options.jsonReport ?? await this.findJsonReport(baseDir); await validateJsonReport(jsonReportPath); let htmlReportDir; if (options.uploadHtml) { htmlReportDir = options.htmlReport ?? await this.findHtmlReport(baseDir); if (htmlReportDir) { await validateHtmlReportDir(htmlReportDir); } } let traceDirectory; if (options.uploadTraces) { const hasTracesInJson = await this.checkTracesInJsonReport(jsonReportPath); if (hasTracesInJson) { traceDirectory = void 0; } else { if (options.traceDir) { await validateTraceDir(options.traceDir); traceDirectory = options.traceDir; } else { traceDirectory = await this.findTraceDir(baseDir); if (traceDirectory) { await validateTraceDir(traceDirectory); } } } } return { jsonReport: jsonReportPath, htmlReport: htmlReportDir, traceDir: traceDirectory }; } /** * Smart JSON report discovery - scan for valid Playwright JSON files */ async findJsonReport(baseDir) { const commonPatterns = [ path.join(baseDir, "report.json"), // Standard Playwright path.join(baseDir, "results.json"), // Alternative name path.join(baseDir, "test-results.json"), // Another common name path.join(baseDir, "playwright-report", "report.json"), // Nested structure path.join(baseDir, "playwright-report", "results.json") // Nested alternative ]; for (const candidate of commonPatterns) { if (await exists(candidate)) { if (await this.isValidPlaywrightJson(candidate)) { return candidate; } } } const jsonFiles = await this.scanForJsonFiles(baseDir); for (const jsonFile of jsonFiles) { if (await this.isValidPlaywrightJson(jsonFile)) { return jsonFile; } } throw new FileSystemError( `No valid Playwright JSON report found in ${baseDir}. \u{1F4A1} Looked for: report.json, results.json, test-results.json \u{1F4A1} Use --json-report <path> to specify exact location` ); } /** * Smart HTML report discovery - find directory containing index.html */ async findHtmlReport(baseDir) { const commonPatterns = [ baseDir, // Current directory path.join(baseDir, "html-report"), // Standard name path.join(baseDir, "playwright-report"), // Standard Playwright path.join(baseDir, "report"), // Alternative path.join(baseDir, "test-report") // Alternative ]; for (const candidate of commonPatterns) { if (await isDirectory(candidate)) { const indexPath = path.join(candidate, "index.html"); if (await exists(indexPath)) { return candidate; } } } const htmlDirs = await this.scanForHtmlDirectories(baseDir); if (htmlDirs.length > 0) { const candidateDir = htmlDirs[0]; if (candidateDir !== void 0) { return candidateDir; } } throw new FileSystemError( `No HTML report directory with index.html found in ${baseDir}. \u{1F4A1} Use --html-report <path> to specify exact location` ); } /** * Smart trace directory discovery */ async findTraceDir(baseDir) { const commonPatterns = [ path.join(baseDir, "trace"), path.join(baseDir, "traces"), path.join(baseDir, "test-results"), path.join(baseDir, "playwright-report", "trace"), path.join(baseDir, "playwright-report", "traces") ]; for (const candidate of commonPatterns) { if (await isDirectory(candidate)) { if (await this.containsTraceFiles(candidate)) { return candidate; } } } const traceDirs = await this.scanForTraceDirectories(baseDir); if (traceDirs.length > 0) { const candidateTraceDir = traceDirs[0]; if (candidateTraceDir !== void 0) { return candidateTraceDir; } } return void 0; } /** * Check if JSON report contains trace files in attachments */ async checkTracesInJsonReport(jsonPath) { try { const content = await readFile(jsonPath); const data = JSON.parse(content); if (!data?.suites || !Array.isArray(data.suites)) { return false; } return this.hasTraceAttachments(data.suites); } catch { return false; } } /** * Recursively check if suites contain trace attachments */ hasTraceAttachments(suites) { for (const suite of suites) { const suiteRecord = suite; if (Array.isArray(suiteRecord.specs)) { for (const spec of suiteRecord.specs) { if (this.hasTraceAttachmentsInSpec(spec)) { return true; } } } if (Array.isArray(suiteRecord.suites)) { if (this.hasTraceAttachments(suiteRecord.suites)) { return true; } } } return false; } /** * Check if a spec contains trace attachments */ hasTraceAttachmentsInSpec(spec) { const specRecord = spec; if (!Array.isArray(specRecord.tests)) return false; for (const test of specRecord.tests) { const testRecord = test; if (!Array.isArray(testRecord.results)) continue; for (const result of testRecord.results) { const resultRecord = result; if (Array.isArray(resultRecord.attachments)) { for (const attachment of resultRecord.attachments) { const attachmentRecord = attachment; if (this.isTraceAttachment(attachmentRecord)) { return true; } } } } } return false; } /** * Check if an attachment is a trace file */ isTraceAttachment(attachment) { const name = attachment.name; const contentType = attachment.contentType; if (!name || !contentType) return false; const lowerName = name.toLowerCase(); const lowerContentType = contentType.toLowerCase(); const traceContentTypes = [ "application/zip", "application/x-zip-compressed", "application/octet-stream" ]; if (traceContentTypes.includes(lowerContentType)) { if (lowerName.includes("trace") || lowerName.endsWith(".trace") || lowerName.endsWith(".zip")) { return true; } } return lowerName.includes("trace") || lowerName.endsWith(".trace"); } /** * Validate if a JSON file is a valid Playwright report */ async isValidPlaywrightJson(filePath) { try { const content = await readFile(filePath); const data = JSON.parse(content); return data && typeof data === "object" && "config" in data && "suites" in data && "stats" in data && Array.isArray(data.suites); } catch { return false; } } /** * Scan directory recursively for JSON files */ async scanForJsonFiles(dir, maxDepth = 2) { const jsonFiles = []; try { await this.scanDirectory( dir, jsonFiles, (file) => file.endsWith(".json"), 0, maxDepth ); } catch { } return jsonFiles; } /** * Scan for directories containing index.html */ async scanForHtmlDirectories(dir, maxDepth = 2) { const htmlDirs = []; try { await this.scanDirectoryForHtml(dir, htmlDirs, 0, maxDepth); } catch { } return htmlDirs; } /** * Scan for directories containing trace files */ async scanForTraceDirectories(dir, maxDepth = 2) { const traceDirs = []; try { await this.scanDirectoryForTraces(dir, traceDirs, 0, maxDepth); } catch { } return traceDirs; } /** * Generic directory scanner for files */ async scanDirectory(dir, results, filter, currentDepth, maxDepth) { if (currentDepth > maxDepth) return; const entries = await readDir(dir); for (const entry of entries) { if (await isDirectory(entry)) { await this.scanDirectory( entry, results, filter, currentDepth + 1, maxDepth ); } else if (filter(entry)) { results.push(entry); } } } /** * Scan for HTML directories */ async scanDirectoryForHtml(dir, results, currentDepth, maxDepth) { if (currentDepth > maxDepth) return; const indexPath = path.join(dir, "index.html"); if (await exists(indexPath)) { results.push(dir); return; } const entries = await readDir(dir); for (const entry of entries) { if (await isDirectory(entry)) { await this.scanDirectoryForHtml( entry, results, currentDepth + 1, maxDepth ); } } } /** * Scan for trace directories */ async scanDirectoryForTraces(dir, results, currentDepth, maxDepth) { if (currentDepth > maxDepth) return; if (await this.containsTraceFiles(dir)) { results.push(dir); return; } const entries = await readDir(dir); for (const entry of entries) { if (await isDirectory(entry)) { await this.scanDirectoryForTraces( entry, results, currentDepth + 1, maxDepth ); } } } /** * Check if directory contains trace files */ async containsTraceFiles(dir) { try { const entries = await readDir(dir); return entries.some( (entry) => entry.endsWith(".zip") || entry.endsWith(".trace") || entry.includes("trace") ); } catch { return false; } } }; // src/core/cache-extractor.ts var CacheExtractor = class { constructor(workingDir) { this.workingDir = workingDir; } workingDir; /** * Extract test failures and metadata for caching */ async extractFailureData() { try { const reportPaths = await this.discoverReports(); if (reportPaths.length === 0) { return this.createEmptyResult(); } const failures = []; const totalSummary = { total: 0, passed: 0, failed: 0, skipped: 0, duration: 0 }; for (const reportPath of reportPaths) { try { const reportData = await parsePlaywrightJson(reportPath); const reportFailures = this.extractFailuresFromReport( reportData, reportPath ); failures.push(...reportFailures); if (reportData.stats) { const stats = reportData.stats; totalSummary.total += (stats.expected || 0) + (stats.unexpected || 0) + (stats.skipped || 0); totalSummary.passed += stats.expected || 0; totalSummary.failed += stats.unexpected || 0; totalSummary.skipped += stats.skipped || 0; totalSummary.duration += Math.round(stats.duration || 0); } } catch (error) { logger.warn( `Failed to parse report ${reportPath}: ${error instanceof Error ? error.message : error}` ); continue; } } return { failures, summary: totalSummary, reportPaths, hasData: reportPaths.length > 0 }; } catch (error) { throw new FileSystemError( `Failed to extract test failure data: ${error instanceof Error ? error.message : "Unknown error"}` ); } } /** * Discover JSON reports using the existing discovery service */ async discoverReports() { try { const discoveryService = new ReportDiscoveryService(this.workingDir); const options = { reportDirectory: this.workingDir, token: "", uploadImages: false, uploadVideos: false, uploadHtml: false, uploadTraces: false, uploadFiles: false, uploadFullJson: false, verbose: false, json: false, environment: "unknown", tags: [] }; const discoveryResult = await discoveryService.discover(options); if (discoveryResult.jsonReport) { return [discoveryResult.jsonReport]; } return []; } catch (error) { if (error instanceof FileSystemError) { console.debug("No JSON reports found:", error.message); } return []; } } /** * Extract failures from a parsed report */ extractFailuresFromReport(reportData, reportPath) { const failures = []; try { const suites = reportData.suites || []; if (Array.isArray(suites)) { for (const suite of suites) { this.extractFailuresFromSuite( suite, failures, "", void 0, void 0 ); } } return failures; } catch (error) { logger.warn( `Failed to extract failures from ${reportPath}: ${error instanceof Error ? error.message : error}` ); return []; } } /** * Recursively extract failures from suite structure */ extractFailuresFromSuite(suite, failures, parentPath, parentFile, project) { try { const suiteFile = suite.file || parentFile; const isProjectSuite = !suite.file && !parentFile && suite.title; const currentProject = isProjectSuite ? suite.title : project; const shouldIncludeInPath = suite.title && (!suite.file || suite.title !== suite.file) && !isProjectSuite; const suitePath = shouldIncludeInPath ? parentPath ? `${parentPath} > ${suite.title}` : suite.title : parentPath || ""; if (suite.suites && Array.isArray(suite.suites)) { for (const nestedSuite of suite.suites) { this.extractFailuresFromSuite( nestedSuite, failures, suitePath, suiteFile, currentProject ); } } if (suite.specs && Array.isArray(suite.specs)) { for (const spec of suite.specs) { this.extractFailuresFromSpec( spec, failures, suitePath, suiteFile, currentProject ); } } if (suite.tests && Array.isArray(suite.tests)) { for (const test of suite.tests) { this.extractFailuresFromTest( test, failures, suitePath, suiteFile, void 0, currentProject ); } } } catch (error) { logger.warn( `Error processing suite: ${error instanceof Error ? error.message : error}` ); } } /** * Extract failures from spec structure (intermediate level between suite and test) */ extractFailuresFromSpec(spec, failures, parentPath, suiteFile, project) { try { const specTitle = spec.title || ""; const specFailed = spec.ok === false; if (spec.tests && Array.isArray(spec.tests) && specFailed) { const test = spec.tests[0]; if (test) { const failure = this.createTestFailure( test, parentPath, suiteFile, specTitle, project ); if (failure) { const isDuplicate = failures.some( (f) => f.file === failure.file && f.testTitle === failure.testTitle && f.project === failure.project ); if (!isDuplicate) { failures.push(failure); } } } } } catch (error) { logger.warn( `Error processing spec: ${error instanceof Error ? error.message : error}` ); } } /** * Extract failures from individual test */ extractFailuresFromTest(test, failures, parentPath, suiteFile, specTitle, project) { try { if (this.isFailedTest(test)) { const failure = this.createTestFailure( test, parentPath, suiteFile, specTitle, project ); if (failure) { const isDuplicate = failures.some( (f) => f.file === failure.file && f.testTitle === failure.testTitle && f.project === failure.project ); if (!isDuplicate) { failures.push(failure); } } } } catch (error) { logger.warn( `Error processing test: ${error instanceof Error ? error.message : error}` ); } } /** * Check if a test item represents a failed test */ isFailedTest(item) { return Boolean( item && (item.status === "failed" || item.outcome === "failed" || item.state === "failed" || item.results?.some((r) => r.status === "failed")) ); } /** * Create TestFailure object from test data */ createTestFailure(test, _parentPath, suiteFile, specTitle, project) { try { let filePath = test.file || test.location?