import CryptoJS from "crypto-js"; import { Schedule } from "../types/execution.types"; import { InputSchedule } from "../types/types"; export const getVerboseSchedule = (schedule: Schedule | undefined) => { if (schedule === undefined) return ""; const { time, days, isBiweekly } = schedule; const timeString = getTimeString12Hr(time); const daysString = days.join(", "); if (days.length === 7) return "Every day at " + timeString; if (days.length === 5 && days.every((day) => ["Mon", "Tue", "Wed", "Thu", "Fri"].includes(day))) return "Every weekday at " + timeString; const scheduleString = isBiweekly ? `Every 2 weeks on ${daysString} at ${timeString}` : `Every week on ${daysString} at ${timeString}`; return scheduleString; }; export function getTimeString12Hr(time: string) { // input "HH:mm:" 24 hr format, example: "12:00", "13:15" // returns "12:00 AM", "01:15 PM" const [hours, minutes] = time.split(":").map(Number); const amPm = hours < 12 ? "AM" : "PM"; const formattedHours = hours % 12 === 0 ? 12 : hours % 12; const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes; return `${formattedHours}:${formattedMinutes} ${amPm}`; } export function log(...args: any[]) { if (process.env.DEBUG === "true") { console.log(...args); } } export function formatTime(date: Date | string | undefined): string { if (!date) return "invalid date"; const d = date instanceof Date ? date : new Date(date); if (isNaN(d.getTime())) return "invalid date"; const options: Intl.DateTimeFormatOptions = { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: true, // 12-hour format with AM/PM }; return d.toLocaleString("en-US", options); } export function processedSchedule(schedule: InputSchedule): Schedule | undefined { if (!schedule) return undefined; const processed: Schedule = { ...schedule, days: schedule.days.map(day => day.toLowerCase().substring(0, 3)).map(day => day.charAt(0).toUpperCase() + day.slice(1)), time: schedule.time, isBiweekly: false, timeZone: parseTimeZone(schedule.timezoneOffset), }; return processed; } export function parseTimeZone(timeZone: string): number { // Parse time zone string like "-02:30" or "+05:30" to -2.5 and 5.5 const sign = timeZone[0] === '+' ? 1 : -1; const [hours, minutes] = timeZone.slice(1).split(':').map(Number); return sign * (hours + minutes / 60); } export function isExpired(expriration_date: number): boolean { // input example: expriration_date: 1753705217, const currentTime = Math.floor(Date.now() / 1000); return expriration_date < currentTime; }; export function secondsToDays(seconds: number): number { return Math.floor(seconds / (24 * 60 * 60)); } export async function getPrivateRepoURI(): Promise { if (process.env.isPrivateRepo !== 'true') { return ""; } const url = process.env.BASE_API_URL + '/api/auth/getPrivateRepoUrl'; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.ACCESS_TOKEN!}`, }, body: JSON.stringify({ project_id: process.env.projectId, }), }); const data = await response?.json(); if (data?.status === true) { return data.url; } console.error("Error fetching private repository clone URI:", data?.error || "Unknown error"); return ""; } /** * name: Execution Name env: shop-blinq branch: main flows: - browser: Chromium viewport: 1280x720 scenarioGroups: - name: Scenario Group 1 tags: - tag1 - tag2 - name: Scenario Group 2 tags: - tag3 - tag4 - browser: Chromium viewport: 1280x720 scenarioGroups: - name: Scenario Group 1 tags: - tag1 - tag2 - name: Scenario Group 2 tags: - tag3 - tag4 schedule: time: 17:30 timezoneOffset: +05:30 # Timezone offset in hours, e.g., +05:30 for IST repeat: daily # Options: daily, weekly, days: # Options: the days if weekly, else empty - monday - tuesday - saturday */ import * as p from '@clack/prompts'; import color from 'picocolors'; export async function showCreateTemplate() { const template = `name: Execution Name env: shop-blinq branch: main flows: - browser: Chromium viewport: 1280x720 scenarioGroups: - name: Scenario Group 1 tags: - tag1 - tag2 - name: Scenario Group 2 tags: - tag3 - tag4 schedule: time: 17:30 timezoneOffset: +05:30 # Timezone offset, e.g., +05:30 for IST repeat: daily # Options: daily, weekly days: # Use only for weekly, otherwise leave empty - monday - tuesday - saturday`; // Intro (title) p.note( color.cyan('Execution Configuration Template\n\n') + color.gray(template), 'YAML Template Example' ); // Extra tips for command usage p.note(`💡 How to use this template: ${color.bold('1.')} Download the template above using option 2, e.g. ${color.yellow('executionTemplate.yaml')}. ${color.bold('2.')} Edit it to suit your execution. ${color.bold('3.')} When ready, run:\n${color.green('npx blinqio-executions-cli --mode=create --filePath=executionTemplate.yaml --token=')} For more details, see ${color.underline(color.blue('https://docs.blinq.io/executions-cli'))} `, 'Instructions' ); } export function printHelp() { const sep = color.gray('────────────────────────────────────────────────────────'); console.log(color.cyan('\nBlinqIO Executions CLI')); console.log(color.gray('A powerful zero-install CLI for managing BlinqIO executions.\n')); console.log(color.bold('USAGE:')); console.log(' npx blinqio-executions-cli [--mode=MODE] [options]\n'); console.log(color.bold('EXAMPLES:')); console.log(color.yellow(' npx blinqio-executions-cli')); console.log(' (interactive terminal mode)'); console.log(color.yellow(' npx blinqio-executions-cli --mode=create --token=YOUR_ACCESS_TOKEN --filePath=./path/to/execution.yaml')); console.log(' (non-interactive: create new execution)'); console.log(color.yellow(' npx blinqio-executions-cli --mode=startRun --token=YOUR_ACCESS_TOKEN --executionId=123')); console.log(' (non-interactive: trigger execution by id)\n'); console.log(sep); console.log(color.bold('MODES:')); console.log(' create Create a new execution configuration from a YAML file'); console.log(' startRun Trigger a new run for an existing execution'); console.log(' stopRun Stop a running execution'); console.log(' getAll Show all execution configurations'); console.log(' getActive View currently active executions'); console.log(' getHistory Show completed executions\n'); console.log(sep); console.log(color.bold('OPTIONS:')); console.log(' --token=TOKEN Your BlinqIO access token (required for non-interactive)'); console.log(' --filePath=FILE Path to an execution YAML file (for --mode=create)'); console.log(' --executionId=ID ID of execution to start/stop'); console.log(' --executionName=NAME Name of execution to start/stop'); console.log(' --debug=true Enable verbose debug output'); console.log(' -h, --help Show this message\n'); console.log(sep); console.log(color.bold('INTERACTIVE MODE FEATURES:')); console.log(' ✓ Guided menu prompts, no flags needed'); console.log(' ✓ YAML template generation'); console.log(' ✓ Formatted execution tables'); console.log(' ✓ Friendly error/help output'); console.log('\nFor more info: ' + color.blue('https://www.docs.blinq.io/test-runs/execution-cli.html')); console.log(''); } export function init_NODE_ENV_BLINQ() { switch (process.env.NODE_ENV_BLINQ!) { case 'dev': process.env.BASE_API_URL = 'https://dev.api.blinq.io'; process.env.EXECUTIONS_SERVER_URL = 'https://dev.executions.api.blinq.io'; break; case 'stage': process.env.BASE_API_URL = 'https://stage.api.blinq.io'; process.env.EXECUTIONS_SERVER_URL = 'https://stage.executions.api.blinq.io'; break; default: process.env.BASE_API_URL = 'https://api.blinq.io'; process.env.EXECUTIONS_SERVER_URL = 'https://executions.api.blinq.io'; } } export function wordWrap(str: string, maxLength: number): string { if (str.length <= maxLength) return str; const words = str.split(' '); let result = ''; let line = ''; for (const word of words) { if ((line + word).length > maxLength) { result += line.trim() + '\n'; line = word + ' '; } else { line += word + ' '; } } return result + line.trim(); } import fs from 'fs'; import path from 'path'; import { EXECUTION_TEMPLATE_BASIC2_PATH, EXECUTION_TEMPLATE_BASIC_PATH } from "./constants"; export interface BasicExecutionYamlOptions { name: string; env: string; branch: string; tags?: string[]; tag?: string; //? for backward compatibility with Mahdi's existing scripts group1Tags?: string[]; group2Tags?: string[]; customTestData?: Record; uploadVideo?: boolean; retryCount?: number; } export function createYamlBasic(options: BasicExecutionYamlOptions): string { let templatePath, template; let tagsList = ''; let group1TagsList = ''; let group2TagsList = ''; if (options.tags) { const tagsArray = options.tags.map(t => t.trim()).filter(Boolean); tagsList = tagsArray.map((tag: string) => ` - ${tag}`).join('\n'); } else if (options.tag) { // fallback for single tag (old behavior) tagsList = ` - ${options.tag}`; } else { const group1TagsArray = options.group1Tags!.map(t => t.trim()).filter(Boolean); group1TagsList = group1TagsArray.map((tag: string) => ` - ${tag}`).join('\n'); const group2TagsArray = options.group2Tags!.map(t => t.trim()).filter(Boolean); group2TagsList = group2TagsArray.map((tag: string) => ` - ${tag}`).join('\n'); } const customTestDataYaml = renderCustomTestData(options?.customTestData ?? {}); if (tagsList) { templatePath = path.join(__dirname, EXECUTION_TEMPLATE_BASIC_PATH); template = fs.readFileSync(templatePath, 'utf-8'); return template .replace(/{{name}}/g, options.name) .replace(/{{env}}/g, options.env) .replace(/{{branch}}/g, options.branch) .replace(/{{customTestData}}/g, customTestDataYaml) .replace(/{{uploadVideo}}/g, options.uploadVideo ? 'true' : 'false') .replace(/{{retryCount}}/g, options.retryCount ? options.retryCount.toString() : '0') .replace(/{{tags}}/g, tagsList); } else { templatePath = path.join(__dirname, EXECUTION_TEMPLATE_BASIC2_PATH); template = fs.readFileSync(templatePath, 'utf-8'); return template .replace(/{{name}}/g, options.name) .replace(/{{env}}/g, options.env) .replace(/{{uploadVideo}}/g, options.uploadVideo ? 'true' : 'false') .replace(/{{customTestData}}/g, customTestDataYaml) .replace(/{{branch}}/g, options.branch) .replace(/{{group1Tags}}/g, group1TagsList) .replace(/{{retryCount}}/g, options.retryCount ? options.retryCount.toString() : '0') .replace(/{{group2Tags}}/g, group2TagsList); } } function renderCustomTestData(customTestData: Record): string { if (!customTestData || Object.keys(customTestData).length === 0) { return ''; } const lines = ['customTestData:']; for (const [key, value] of Object.entries(customTestData)) { lines.push(` ${key}: ${value}`); } return lines.join('\n'); } export function assertNonInteractiveCreateInput(args: any) { const supportedProps = new Set([ "env", "name", "branch", "uploadFailedVideos", "retryCount", "tag", "tags", "group1Tags", "group2Tags", "filePath", "token", "mode", "NODE_ENV_BLINQ", "_", ]); const unknownKeys = Object.keys(args).filter( (key) => !supportedProps.has(key) ); if (unknownKeys.length > 0) { throw new Error( `Unsupported argument(s): ${unknownKeys.join(", ")}\n` + `Supported arguments are:\n` + `• env, name, branch (* Required)\n` + `• tag OR tags OR group1Tags + group2Tags (* Required)\n` + `• uploadFailedVideos, retryCount (* Optional)\n` + `• filePath (* Alternative to above options)` ); } } export function assertNonInteractiveStartRunInput(args: any) { const supportedProps = new Set([ "token", "mode", "NODE_ENV_BLINQ", "_", "executionId", "executionName", "maxThreads", "uploadFailedVideos", "retryCount", ]); const unknownKeys = Object.keys(args).filter( (key) => !supportedProps.has(key) ); if (unknownKeys.length > 0) { throw new Error( `Unsupported argument(s): ${unknownKeys.join(", ")}\n` + `Supported arguments are:\n` + `• env, name, branch (* Required)\n` + `• tag OR tags OR group1Tags + group2Tags (* Required)\n` + `• uploadFailedVideos, retryCount (* Optional)\n` + `• filePath (* Alternative to above options)` ); } }