tdpw
Version:
CLI tool for uploading Playwright test reports to TestDino platform with TestDino storage support
1,662 lines (1,652 loc) • 233 kB
JavaScript
#!/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?