@qate/cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -46,6 +46,7 @@ const RestApiExecutor_1 = require("./RestApiExecutor");
46
46
  const fs = __importStar(require("fs"));
47
47
  const path = __importStar(require("path"));
48
48
  const os = __importStar(require("os"));
49
+ const JunitXmlGenerator_1 = require("./JunitXmlGenerator");
49
50
  const VERSION = '1.0.0';
50
51
  const program = new commander_1.Command();
51
52
  // Helper to handle API errors with detailed feedback
@@ -126,6 +127,39 @@ function getConfig(options) {
126
127
  }
127
128
  return { apiKey, baseUrl };
128
129
  }
130
+ /**
131
+ * Detect PR number from CI environment variables.
132
+ * Supports GitHub Actions, GitLab CI, Bitbucket Pipelines, Azure DevOps, Jenkins, CircleCI.
133
+ */
134
+ function detectPRFromEnvironment() {
135
+ // GitHub Actions: GITHUB_REF = refs/pull/123/merge
136
+ const githubRef = process.env.GITHUB_REF;
137
+ if (githubRef?.includes('/pull/')) {
138
+ const match = githubRef.match(/\/pull\/(\d+)\//);
139
+ if (match)
140
+ return parseInt(match[1]);
141
+ }
142
+ // GitLab CI
143
+ if (process.env.CI_MERGE_REQUEST_IID)
144
+ return parseInt(process.env.CI_MERGE_REQUEST_IID);
145
+ // Bitbucket Pipelines
146
+ if (process.env.BITBUCKET_PR_ID)
147
+ return parseInt(process.env.BITBUCKET_PR_ID);
148
+ // Azure DevOps
149
+ if (process.env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER)
150
+ return parseInt(process.env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER);
151
+ // Jenkins
152
+ if (process.env.CHANGE_ID)
153
+ return parseInt(process.env.CHANGE_ID);
154
+ // CircleCI: CIRCLE_PULL_REQUEST = https://github.com/org/repo/pull/123
155
+ const circlePR = process.env.CIRCLE_PULL_REQUEST;
156
+ if (circlePR) {
157
+ const match = circlePR.match(/\/(\d+)$/);
158
+ if (match)
159
+ return parseInt(match[1]);
160
+ }
161
+ return null;
162
+ }
129
163
  // Helper to display execution status
130
164
  function displayStatus(status, type) {
131
165
  const name = type === 'testset' ? status.testSetName : status.sequenceName;
@@ -172,171 +206,422 @@ program
172
206
  .name('qate')
173
207
  .description('Qate CLI for CI/CD pipeline integration\n\nGenerate and run Playwright tests from your Qate test definitions.')
174
208
  .version(VERSION)
175
- .addHelpText('after', `
176
- Examples:
177
- $ qate list List all test sets
178
- $ qate generate -n "My Tests" -o ./e2e Generate Playwright tests (web apps)
179
- $ qate run -n "API Tests" --wait Run REST/SOAP tests locally
180
- $ qate export:testset -n "Tests" -o ./out Export as Playwright or Axios tests
181
- $ qate status -e ci_xxx_xxx Check execution status
182
-
183
- Documentation:
184
- https://docs.qate.io/cli
185
-
186
- Environment Variables:
187
- QATE_API_KEY Your Qate API key (required)
188
- QATE_API_URL Qate API URL (default: https://api.qate.ai)
209
+ .addHelpText('after', `
210
+ Examples:
211
+ $ qate list List all test sets
212
+ $ qate generate -n "My Tests" -o ./e2e Generate Playwright tests (web apps)
213
+ $ qate run -n "API Tests" --wait Run REST/SOAP tests locally
214
+ $ qate export:testset -n "Tests" -o ./out Export as Playwright or Axios tests
215
+ $ qate status -e ci_xxx_xxx Check execution status
216
+
217
+ Documentation:
218
+ https://docs.qate.io/cli
219
+
220
+ Environment Variables:
221
+ QATE_API_KEY Your Qate API key (required)
222
+ QATE_API_URL Qate API URL (default: https://api.qate.ai)
189
223
  `);
190
224
  // ================== TEST SET COMMANDS ==================
191
225
  program
192
226
  .command('run:testset')
193
227
  .alias('run')
194
228
  .description('Execute a test set locally (REST API and SOAP only - use "generate" for web apps)')
195
- .requiredOption('-n, --name <name>', 'Name of the test set to execute')
229
+ .option('-n, --name <name>', 'Name of the test set to execute')
196
230
  .option('-a, --api-key <key>', 'API key (or use QATE_API_KEY env var)')
197
231
  .option('-u, --url <url>', 'Qate API URL (or use QATE_API_URL env var)')
198
- .option('--app <applicationId>', 'Application ID (if test set name is not unique)')
232
+ .option('--app <applicationId>', 'Application ID (required for --smart, optional otherwise)')
233
+ .option('--smart', 'Use AI to select tests based on PR changes')
234
+ .option('--pr <number>', 'PR number (auto-detected from CI environment if not specified)')
235
+ .option('--repo-type <type>', 'Repository type for --smart: frontend or backend', 'frontend')
199
236
  .option('-w, --wait', 'Wait for execution to complete')
200
237
  .option('--timeout <seconds>', 'Timeout for waiting (default: 600)', '600')
201
238
  .option('--json', 'Output results as JSON')
239
+ .option('--junit <path>', 'Write JUnit XML report to file')
202
240
  .option('-v, --verbose', 'Show detailed step-by-step output')
203
241
  .action(async (options) => {
204
242
  try {
243
+ // Validate: either --name or --smart is required
244
+ if (!options.smart && !options.name) {
245
+ console.error(chalk_1.default.red('Error: Either --name <test-set-name> or --smart is required.'));
246
+ console.error(chalk_1.default.gray(' Use --name to run a specific test set'));
247
+ console.error(chalk_1.default.gray(' Use --smart to let AI select tests based on PR changes'));
248
+ process.exit(1);
249
+ }
250
+ if (options.smart && !options.app) {
251
+ console.error(chalk_1.default.red('Error: --app <applicationId> is required when using --smart.'));
252
+ console.error(chalk_1.default.gray(' Find your application ID in Qate: Settings > Applications'));
253
+ process.exit(1);
254
+ }
205
255
  const { apiKey, baseUrl } = getConfig(options);
206
256
  const client = new client_1.QateClient(baseUrl, apiKey);
207
- if (!options.json) {
208
- console.log(chalk_1.default.blue(`Fetching test set: ${options.name}`));
209
- }
210
- // First, export the test set to check application type
211
- const exportData = await client.exportTestSet(options.name, options.app);
212
- const appType = exportData.application?.type || 'web';
213
- // REST API apps execute locally in the CLI
214
- if ((0, RestApiExecutor_1.isRestApiApp)(appType)) {
257
+ if (options.smart) {
258
+ // Smart run: AI-powered test selection based on PR changes
259
+ const prNumber = options.pr ? parseInt(options.pr) : detectPRFromEnvironment();
260
+ if (!prNumber) {
261
+ console.error(chalk_1.default.red('Error: Could not detect PR number from CI environment.'));
262
+ console.error(chalk_1.default.gray(' Use --pr <number> to specify the PR number explicitly.'));
263
+ console.error(chalk_1.default.gray(' Auto-detection supports: GitHub Actions, GitLab CI, Bitbucket Pipelines,'));
264
+ console.error(chalk_1.default.gray(' Azure DevOps, Jenkins, CircleCI'));
265
+ process.exit(1);
266
+ }
215
267
  if (!options.json) {
216
- console.log(chalk_1.default.cyan(`REST API application detected - executing locally`));
217
- console.log(chalk_1.default.gray(`Application: ${exportData.application.name}`));
218
- console.log(chalk_1.default.gray(`Base URL: ${exportData.application.url}`));
219
- console.log(chalk_1.default.gray(`Tests: ${exportData.tests.length}`));
220
- console.log();
268
+ console.log(chalk_1.default.blue(`Smart run: analyzing PR #${prNumber}...`));
269
+ console.log(chalk_1.default.gray(` Application: ${options.app}`));
270
+ console.log(chalk_1.default.gray(` Repository type: ${options.repoType}`));
271
+ console.log(chalk_1.default.gray(' Analyzing PR changes with AI...'));
221
272
  }
222
- // Create execution record for tracking
223
- let executionId;
224
- try {
225
- const generateResult = await client.createGenerateExecution(options.name, undefined, options.app, 'cli');
226
- executionId = generateResult.executionId;
227
- if (!options.json) {
228
- console.log(chalk_1.default.gray(`Execution ID: ${executionId}`));
273
+ // Call smart-generate to get AI-selected tests (also checks execution limits + token balance)
274
+ const exportData = await client.smartGenerate(options.app, prNumber, options.repoType);
275
+ const executionId = exportData.executionId;
276
+ if (exportData.tests.length === 0) {
277
+ if (options.json) {
278
+ console.log(JSON.stringify({
279
+ status: 'skipped',
280
+ message: 'AI analysis found no tests to execute for this PR',
281
+ }, null, 2));
229
282
  }
230
- }
231
- catch (err) {
232
- if (!options.json) {
233
- console.log(chalk_1.default.yellow('Note: Could not create execution record'));
283
+ else {
284
+ console.log(chalk_1.default.yellow('\nAI analysis found no tests to execute for this PR.'));
234
285
  }
286
+ process.exit(0);
235
287
  }
236
- // Execute tests locally
237
288
  if (!options.json) {
238
- console.log(chalk_1.default.blue(`\nExecuting ${exportData.tests.length} tests...\n`));
289
+ console.log(chalk_1.default.green(`\nAI selected ${exportData.tests.length} test(s) to execute`));
239
290
  }
240
- const results = await (0, RestApiExecutor_1.executeTests)(exportData.tests, exportData.application.url, { verbose: options.verbose },
241
- // Test progress callback
242
- (testResult, index, total) => {
291
+ const appType = exportData.application?.type || 'web';
292
+ if ((0, RestApiExecutor_1.isRestApiApp)(appType)) {
293
+ // REST API / SOAP: execute locally
243
294
  if (!options.json) {
244
- console.log((0, RestApiExecutor_1.formatTestResult)(testResult, options.verbose));
295
+ console.log(chalk_1.default.cyan(`\nREST API application detected - executing locally`));
296
+ console.log(chalk_1.default.gray(`Application: ${exportData.application.name}`));
297
+ console.log(chalk_1.default.gray(`Base URL: ${exportData.application.url}`));
298
+ console.log(chalk_1.default.gray(`Tests: ${exportData.tests.length}`));
299
+ if (executionId) {
300
+ console.log(chalk_1.default.gray(`Execution ID: ${executionId}`));
301
+ }
302
+ console.log();
245
303
  }
246
- },
247
- // Step progress callback (for verbose mode)
248
- options.verbose ? (progress, stepResult) => {
249
- // Already handled in formatTestResult
250
- } : undefined);
251
- // Calculate summary
252
- const summary = (0, RestApiExecutor_1.calculateSummary)(results);
253
- // Report results to backend
254
- if (executionId) {
255
- try {
256
- await client.reportResults(executionId, results, summary, 'cli', {
257
- os: os.platform(),
258
- nodeVersion: process.version
259
- });
304
+ if (!options.json) {
305
+ console.log(chalk_1.default.blue(`Executing ${exportData.tests.length} tests...\n`));
306
+ }
307
+ const results = await (0, RestApiExecutor_1.executeTests)(exportData.tests, exportData.application.url, { verbose: options.verbose, client, applicationId: exportData.application.id }, (testResult, index, total) => {
260
308
  if (!options.json) {
261
- console.log(chalk_1.default.gray(`\nResults reported to Qate`));
309
+ console.log((0, RestApiExecutor_1.formatTestResult)(testResult, options.verbose));
310
+ }
311
+ }, options.verbose ? (progress, stepResult) => { } : undefined);
312
+ const summary = (0, RestApiExecutor_1.calculateSummary)(results);
313
+ // Report results to backend (pass applicationId for smart execution)
314
+ if (executionId) {
315
+ try {
316
+ await client.reportResults(executionId, results, summary, 'cli', { os: os.platform(), nodeVersion: process.version }, exportData.application.id);
317
+ if (!options.json) {
318
+ console.log(chalk_1.default.gray(`\nResults reported to Qate`));
319
+ }
320
+ }
321
+ catch (err) {
322
+ if (!options.json) {
323
+ console.log(chalk_1.default.yellow('Note: Could not report results to backend'));
324
+ }
262
325
  }
263
326
  }
264
- catch (err) {
265
- if (!options.json) {
266
- console.log(chalk_1.default.yellow('Note: Could not report results to backend'));
327
+ // Write JUnit XML report if requested
328
+ if (options.junit) {
329
+ try {
330
+ const junitXml = (0, JunitXmlGenerator_1.generateJunitXml)(results, 'Smart PR Analysis');
331
+ const junitPath = path.resolve(options.junit);
332
+ const junitDir = path.dirname(junitPath);
333
+ if (!fs.existsSync(junitDir)) {
334
+ fs.mkdirSync(junitDir, { recursive: true });
335
+ }
336
+ fs.writeFileSync(junitPath, junitXml, 'utf-8');
337
+ if (!options.json) {
338
+ console.log(chalk_1.default.gray(`JUnit XML report written to: ${junitPath}`));
339
+ }
340
+ }
341
+ catch (err) {
342
+ if (!options.json) {
343
+ console.log(chalk_1.default.yellow(`Warning: Could not write JUnit XML report: ${err.message || err}`));
344
+ }
267
345
  }
268
346
  }
347
+ // Display final summary
348
+ if (options.json) {
349
+ console.log(JSON.stringify({
350
+ executionId,
351
+ mode: 'smart',
352
+ prNumber,
353
+ status: summary.status,
354
+ summary: {
355
+ total: summary.total,
356
+ passed: summary.passed,
357
+ failed: summary.failed,
358
+ error: summary.error
359
+ },
360
+ tests: results.map(r => ({
361
+ testId: r.testId,
362
+ testName: r.testName,
363
+ status: r.status,
364
+ duration: r.duration
365
+ }))
366
+ }, null, 2));
367
+ }
368
+ else {
369
+ console.log();
370
+ console.log(chalk_1.default.blue(`Summary`));
371
+ console.log(chalk_1.default.gray(`─────────────────────────────────────`));
372
+ console.log(` Total: ${summary.total}`);
373
+ console.log(` Passed: ${chalk_1.default.green(summary.passed.toString())}`);
374
+ console.log(` Failed: ${chalk_1.default.red(summary.failed.toString())}`);
375
+ console.log(` Error: ${chalk_1.default.red(summary.error.toString())}`);
376
+ console.log();
377
+ const statusColor = summary.status === 'passed' ? chalk_1.default.green : chalk_1.default.red;
378
+ console.log(`Status: ${statusColor(summary.status.toUpperCase())}`);
379
+ }
380
+ process.exit(summary.status === 'passed' ? 0 : 1);
269
381
  }
270
- // Display final summary
271
- if (options.json) {
272
- console.log(JSON.stringify({
273
- executionId,
274
- testSetName: exportData.testSet?.name,
275
- status: summary.status,
276
- summary: {
277
- total: summary.total,
278
- passed: summary.passed,
279
- failed: summary.failed,
280
- error: summary.error
281
- },
282
- tests: results.map(r => ({
283
- testId: r.testId,
284
- testName: r.testName,
285
- status: r.status,
286
- duration: r.duration
287
- }))
288
- }, null, 2));
382
+ else if (appType === 'desktop') {
383
+ // Desktop: trigger remote execution via smart-execute endpoint
384
+ if (!options.json) {
385
+ console.log(chalk_1.default.cyan('\nDesktop application detected - executing via connected agent'));
386
+ console.log(chalk_1.default.gray(`Application: ${exportData.application.name}`));
387
+ }
388
+ const testIds = exportData.tests.map(t => t.id);
389
+ const result = await client.smartExecute(options.app, testIds, prNumber);
390
+ if (!options.json) {
391
+ console.log(chalk_1.default.gray(`Execution ID: ${result.executionId}`));
392
+ console.log(chalk_1.default.blue('\nWaiting for execution to complete...\n'));
393
+ }
394
+ const timeoutMs = parseInt(options.timeout) * 1000;
395
+ // MUST pass testCount as expectedCount or polling hangs forever
396
+ const status = await client.pollExecution(result.executionId, 'testset', timeoutMs, 5000, result.testCount);
397
+ // Write JUnit XML report if requested
398
+ if (options.junit) {
399
+ try {
400
+ const junitXml = (0, JunitXmlGenerator_1.generateJunitXmlFromStatus)(status, 'Smart PR Analysis');
401
+ const junitPath = path.resolve(options.junit);
402
+ const junitDir = path.dirname(junitPath);
403
+ if (!fs.existsSync(junitDir)) {
404
+ fs.mkdirSync(junitDir, { recursive: true });
405
+ }
406
+ fs.writeFileSync(junitPath, junitXml, 'utf-8');
407
+ if (!options.json) {
408
+ console.log(chalk_1.default.gray(`JUnit XML report written to: ${junitPath}`));
409
+ }
410
+ }
411
+ catch (err) {
412
+ if (!options.json) {
413
+ console.log(chalk_1.default.yellow(`Warning: Could not write JUnit XML report: ${err.message || err}`));
414
+ }
415
+ }
416
+ }
417
+ if (options.json) {
418
+ console.log(JSON.stringify({ ...status, mode: 'smart', prNumber }, null, 2));
419
+ }
420
+ else {
421
+ displayStatus(status, 'testset');
422
+ }
423
+ process.exit(status.status === 'passed' ? 0 : 1);
289
424
  }
290
425
  else {
426
+ // Web apps should use 'qate generate --smart'
427
+ console.error(chalk_1.default.red('\nError: "qate run --smart" is not supported for web applications.'));
291
428
  console.log();
292
- console.log(chalk_1.default.blue(`Summary`));
293
- console.log(chalk_1.default.gray(`─────────────────────────────────────`));
294
- console.log(` Total: ${summary.total}`);
295
- console.log(` Passed: ${chalk_1.default.green(summary.passed.toString())}`);
296
- console.log(` Failed: ${chalk_1.default.red(summary.failed.toString())}`);
297
- console.log(` Error: ${chalk_1.default.red(summary.error.toString())}`);
429
+ console.log(chalk_1.default.yellow('For web applications, use "qate generate --smart" to create Playwright tests:'));
298
430
  console.log();
299
- const statusColor = summary.status === 'passed' ? chalk_1.default.green :
300
- summary.status === 'failed' ? chalk_1.default.red : chalk_1.default.red;
301
- console.log(`Status: ${statusColor(summary.status.toUpperCase())}`);
431
+ console.log(chalk_1.default.white(` qate generate --smart --app ${options.app} -o ./e2e`));
432
+ console.log(chalk_1.default.white(` cd ./e2e && npm install`));
433
+ console.log(chalk_1.default.white(` npx playwright test`));
434
+ console.log();
435
+ console.log(chalk_1.default.gray('This generates Playwright test files that you can run locally or in CI/CD.'));
436
+ process.exit(1);
302
437
  }
303
- process.exit(summary.status === 'passed' ? 0 : 1);
304
438
  }
305
- else if (appType === 'desktop') {
306
- // Desktop apps: trigger server-side execution via connected agent, then poll
439
+ else {
440
+ // Normal flow (--name provided)
307
441
  if (!options.json) {
308
- console.log(chalk_1.default.cyan('Desktop application detected - executing via connected agent'));
309
- console.log(chalk_1.default.gray(`Application: ${exportData.application.name}`));
442
+ console.log(chalk_1.default.blue(`Fetching test set: ${options.name}`));
310
443
  }
311
- const result = await client.executeTestSet(options.name, options.app);
312
- if (!options.json) {
313
- console.log(chalk_1.default.gray(`Execution ID: ${result.executionId}`));
314
- console.log(chalk_1.default.blue('\nWaiting for execution to complete...\n'));
444
+ // First, export the test set to check application type
445
+ const exportData = await client.exportTestSet(options.name, options.app);
446
+ const appType = exportData.application?.type || 'web';
447
+ // REST API apps execute locally in the CLI
448
+ if ((0, RestApiExecutor_1.isRestApiApp)(appType)) {
449
+ if (!options.json) {
450
+ console.log(chalk_1.default.cyan(`REST API application detected - executing locally`));
451
+ console.log(chalk_1.default.gray(`Application: ${exportData.application.name}`));
452
+ console.log(chalk_1.default.gray(`Base URL: ${exportData.application.url}`));
453
+ console.log(chalk_1.default.gray(`Tests: ${exportData.tests.length}`));
454
+ console.log();
455
+ }
456
+ // Create execution record for tracking
457
+ let executionId;
458
+ try {
459
+ const generateResult = await client.createGenerateExecution(options.name, undefined, options.app, 'cli');
460
+ executionId = generateResult.executionId;
461
+ if (!options.json) {
462
+ console.log(chalk_1.default.gray(`Execution ID: ${executionId}`));
463
+ }
464
+ }
465
+ catch (err) {
466
+ if (err.response?.status === 429 || err.response?.status === 402) {
467
+ const msg = err.response?.data?.message || 'Execution not allowed';
468
+ if (options.json) {
469
+ console.log(JSON.stringify({ error: err.response?.data?.error || 'limit_reached', message: msg }));
470
+ }
471
+ else {
472
+ console.error(chalk_1.default.red(msg));
473
+ }
474
+ process.exit(1);
475
+ }
476
+ if (!options.json) {
477
+ console.log(chalk_1.default.yellow('Note: Could not create execution record'));
478
+ }
479
+ }
480
+ // Execute tests locally
481
+ if (!options.json) {
482
+ console.log(chalk_1.default.blue(`\nExecuting ${exportData.tests.length} tests...\n`));
483
+ }
484
+ const results = await (0, RestApiExecutor_1.executeTests)(exportData.tests, exportData.application.url, { verbose: options.verbose, client, applicationId: exportData.application.id },
485
+ // Test progress callback
486
+ (testResult, index, total) => {
487
+ if (!options.json) {
488
+ console.log((0, RestApiExecutor_1.formatTestResult)(testResult, options.verbose));
489
+ }
490
+ },
491
+ // Step progress callback (for verbose mode)
492
+ options.verbose ? (progress, stepResult) => {
493
+ // Already handled in formatTestResult
494
+ } : undefined);
495
+ // Calculate summary
496
+ const summary = (0, RestApiExecutor_1.calculateSummary)(results);
497
+ // Report results to backend
498
+ if (executionId) {
499
+ try {
500
+ await client.reportResults(executionId, results, summary, 'cli', {
501
+ os: os.platform(),
502
+ nodeVersion: process.version
503
+ });
504
+ if (!options.json) {
505
+ console.log(chalk_1.default.gray(`\nResults reported to Qate`));
506
+ }
507
+ }
508
+ catch (err) {
509
+ if (!options.json) {
510
+ console.log(chalk_1.default.yellow('Note: Could not report results to backend'));
511
+ }
512
+ }
513
+ }
514
+ // Write JUnit XML report if requested
515
+ if (options.junit) {
516
+ try {
517
+ const junitXml = (0, JunitXmlGenerator_1.generateJunitXml)(results, exportData.testSet?.name || options.name);
518
+ const junitPath = path.resolve(options.junit);
519
+ const junitDir = path.dirname(junitPath);
520
+ if (!fs.existsSync(junitDir)) {
521
+ fs.mkdirSync(junitDir, { recursive: true });
522
+ }
523
+ fs.writeFileSync(junitPath, junitXml, 'utf-8');
524
+ if (!options.json) {
525
+ console.log(chalk_1.default.gray(`JUnit XML report written to: ${junitPath}`));
526
+ }
527
+ }
528
+ catch (err) {
529
+ if (!options.json) {
530
+ console.log(chalk_1.default.yellow(`Warning: Could not write JUnit XML report: ${err.message || err}`));
531
+ }
532
+ }
533
+ }
534
+ // Display final summary
535
+ if (options.json) {
536
+ console.log(JSON.stringify({
537
+ executionId,
538
+ testSetName: exportData.testSet?.name,
539
+ status: summary.status,
540
+ summary: {
541
+ total: summary.total,
542
+ passed: summary.passed,
543
+ failed: summary.failed,
544
+ error: summary.error
545
+ },
546
+ tests: results.map(r => ({
547
+ testId: r.testId,
548
+ testName: r.testName,
549
+ status: r.status,
550
+ duration: r.duration
551
+ }))
552
+ }, null, 2));
553
+ }
554
+ else {
555
+ console.log();
556
+ console.log(chalk_1.default.blue(`Summary`));
557
+ console.log(chalk_1.default.gray(`─────────────────────────────────────`));
558
+ console.log(` Total: ${summary.total}`);
559
+ console.log(` Passed: ${chalk_1.default.green(summary.passed.toString())}`);
560
+ console.log(` Failed: ${chalk_1.default.red(summary.failed.toString())}`);
561
+ console.log(` Error: ${chalk_1.default.red(summary.error.toString())}`);
562
+ console.log();
563
+ const statusColor = summary.status === 'passed' ? chalk_1.default.green :
564
+ summary.status === 'failed' ? chalk_1.default.red : chalk_1.default.red;
565
+ console.log(`Status: ${statusColor(summary.status.toUpperCase())}`);
566
+ }
567
+ process.exit(summary.status === 'passed' ? 0 : 1);
315
568
  }
316
- const timeoutMs = parseInt(options.timeout) * 1000;
317
- const status = await client.pollExecution(result.executionId, 'testset', timeoutMs);
318
- if (options.json) {
319
- console.log(JSON.stringify(status, null, 2));
569
+ else if (appType === 'desktop') {
570
+ // Desktop apps: trigger server-side execution via connected agent, then poll
571
+ if (!options.json) {
572
+ console.log(chalk_1.default.cyan('Desktop application detected - executing via connected agent'));
573
+ console.log(chalk_1.default.gray(`Application: ${exportData.application.name}`));
574
+ }
575
+ const result = await client.executeTestSet(options.name, options.app);
576
+ if (!options.json) {
577
+ console.log(chalk_1.default.gray(`Execution ID: ${result.executionId}`));
578
+ console.log(chalk_1.default.blue('\nWaiting for execution to complete...\n'));
579
+ }
580
+ const timeoutMs = parseInt(options.timeout) * 1000;
581
+ const status = await client.pollExecution(result.executionId, 'testset', timeoutMs);
582
+ // Write JUnit XML report if requested
583
+ if (options.junit) {
584
+ try {
585
+ const junitXml = (0, JunitXmlGenerator_1.generateJunitXmlFromStatus)(status, exportData.testSet?.name || options.name);
586
+ const junitPath = path.resolve(options.junit);
587
+ const junitDir = path.dirname(junitPath);
588
+ if (!fs.existsSync(junitDir)) {
589
+ fs.mkdirSync(junitDir, { recursive: true });
590
+ }
591
+ fs.writeFileSync(junitPath, junitXml, 'utf-8');
592
+ if (!options.json) {
593
+ console.log(chalk_1.default.gray(`JUnit XML report written to: ${junitPath}`));
594
+ }
595
+ }
596
+ catch (err) {
597
+ if (!options.json) {
598
+ console.log(chalk_1.default.yellow(`Warning: Could not write JUnit XML report: ${err.message || err}`));
599
+ }
600
+ }
601
+ }
602
+ if (options.json) {
603
+ console.log(JSON.stringify(status, null, 2));
604
+ }
605
+ else {
606
+ displayStatus(status, 'testset');
607
+ }
608
+ process.exit(status.status === 'passed' ? 0 : 1);
320
609
  }
321
610
  else {
322
- displayStatus(status, 'testset');
611
+ // Web apps should use 'qate generate' to create Playwright tests
612
+ console.error(chalk_1.default.red('\nError: "qate run" is not supported for web applications.'));
613
+ console.log();
614
+ console.log(chalk_1.default.yellow('For web applications, use "qate generate" to create Playwright tests:'));
615
+ console.log();
616
+ console.log(chalk_1.default.white(` qate generate -n "${options.name}" -o ./e2e`));
617
+ console.log(chalk_1.default.white(` cd ./e2e && npm install`));
618
+ console.log(chalk_1.default.white(` npx playwright test`));
619
+ console.log();
620
+ console.log(chalk_1.default.gray('This generates Playwright test files that you can run locally or in CI/CD.'));
621
+ console.log(chalk_1.default.gray('Results are automatically reported back to Qate.'));
622
+ process.exit(1);
323
623
  }
324
- process.exit(status.status === 'passed' ? 0 : 1);
325
- }
326
- else {
327
- // Web apps should use 'qate generate' to create Playwright tests
328
- console.error(chalk_1.default.red('\nError: "qate run" is not supported for web applications.'));
329
- console.log();
330
- console.log(chalk_1.default.yellow('For web applications, use "qate generate" to create Playwright tests:'));
331
- console.log();
332
- console.log(chalk_1.default.white(` qate generate -n "${options.name}" -o ./e2e`));
333
- console.log(chalk_1.default.white(` cd ./e2e && npm install`));
334
- console.log(chalk_1.default.white(` npx playwright test`));
335
- console.log();
336
- console.log(chalk_1.default.gray('This generates Playwright test files that you can run locally or in CI/CD.'));
337
- console.log(chalk_1.default.gray('Results are automatically reported back to Qate.'));
338
- process.exit(1);
339
- }
624
+ } // end normal flow else
340
625
  }
341
626
  catch (error) {
342
627
  handleApiError(error);
@@ -429,8 +714,7 @@ program
429
714
  outputDir: options.output,
430
715
  provider: 'local',
431
716
  orchestration: 'websocket',
432
- browsers: ['chromium'],
433
- browser: 'chrome',
717
+ browser: 'chromium',
434
718
  browserVersion: 'latest',
435
719
  os: 'windows',
436
720
  osVersion: '11',
@@ -485,6 +769,7 @@ program
485
769
  .option('-w, --wait', 'Wait for execution to complete')
486
770
  .option('--timeout <seconds>', 'Timeout for waiting (default: 600)', '600')
487
771
  .option('--json', 'Output results as JSON')
772
+ .option('--junit <path>', 'Write JUnit XML report to file')
488
773
  .option('-v, --verbose', 'Show detailed step-by-step output')
489
774
  .action(async (options) => {
490
775
  try {
@@ -515,6 +800,16 @@ program
515
800
  }
516
801
  }
517
802
  catch (err) {
803
+ if (err.response?.status === 429 || err.response?.status === 402) {
804
+ const msg = err.response?.data?.message || 'Execution not allowed';
805
+ if (options.json) {
806
+ console.log(JSON.stringify({ error: err.response?.data?.error || 'limit_reached', message: msg }));
807
+ }
808
+ else {
809
+ console.error(chalk_1.default.red(msg));
810
+ }
811
+ process.exit(1);
812
+ }
518
813
  if (!options.json) {
519
814
  console.log(chalk_1.default.yellow('Note: Could not create execution record'));
520
815
  }
@@ -527,7 +822,7 @@ program
527
822
  let stoppedEarly = false;
528
823
  for (let i = 0; i < exportData.tests.length; i++) {
529
824
  const test = exportData.tests[i];
530
- const testResults = await (0, RestApiExecutor_1.executeTests)([test], exportData.application.url, { verbose: options.verbose }, (testResult) => {
825
+ const testResults = await (0, RestApiExecutor_1.executeTests)([test], exportData.application.url, { verbose: options.verbose, client, applicationId: exportData.application.id }, (testResult) => {
531
826
  if (!options.json) {
532
827
  console.log((0, RestApiExecutor_1.formatTestResult)(testResult, options.verbose));
533
828
  }
@@ -561,6 +856,26 @@ program
561
856
  }
562
857
  }
563
858
  }
859
+ // Write JUnit XML report if requested
860
+ if (options.junit) {
861
+ try {
862
+ const junitXml = (0, JunitXmlGenerator_1.generateJunitXml)(results, exportData.testSequence?.name || options.name);
863
+ const junitPath = path.resolve(options.junit);
864
+ const junitDir = path.dirname(junitPath);
865
+ if (!fs.existsSync(junitDir)) {
866
+ fs.mkdirSync(junitDir, { recursive: true });
867
+ }
868
+ fs.writeFileSync(junitPath, junitXml, 'utf-8');
869
+ if (!options.json) {
870
+ console.log(chalk_1.default.gray(`JUnit XML report written to: ${junitPath}`));
871
+ }
872
+ }
873
+ catch (err) {
874
+ if (!options.json) {
875
+ console.log(chalk_1.default.yellow(`Warning: Could not write JUnit XML report: ${err.message || err}`));
876
+ }
877
+ }
878
+ }
564
879
  // Display final summary
565
880
  if (options.json) {
566
881
  console.log(JSON.stringify({
@@ -616,6 +931,26 @@ program
616
931
  }
617
932
  const timeoutMs = parseInt(options.timeout) * 1000;
618
933
  const status = await client.pollExecution(result.executionId, 'sequence', timeoutMs);
934
+ // Write JUnit XML report if requested
935
+ if (options.junit) {
936
+ try {
937
+ const junitXml = (0, JunitXmlGenerator_1.generateJunitXmlFromStatus)(status, exportData.testSequence?.name || options.name);
938
+ const junitPath = path.resolve(options.junit);
939
+ const junitDir = path.dirname(junitPath);
940
+ if (!fs.existsSync(junitDir)) {
941
+ fs.mkdirSync(junitDir, { recursive: true });
942
+ }
943
+ fs.writeFileSync(junitPath, junitXml, 'utf-8');
944
+ if (!options.json) {
945
+ console.log(chalk_1.default.gray(`JUnit XML report written to: ${junitPath}`));
946
+ }
947
+ }
948
+ catch (err) {
949
+ if (!options.json) {
950
+ console.log(chalk_1.default.yellow(`Warning: Could not write JUnit XML report: ${err.message || err}`));
951
+ }
952
+ }
953
+ }
619
954
  if (options.json) {
620
955
  console.log(JSON.stringify(status, null, 2));
621
956
  }
@@ -729,8 +1064,7 @@ program
729
1064
  outputDir: options.output,
730
1065
  provider: 'local',
731
1066
  orchestration: 'websocket',
732
- browsers: ['chromium'],
733
- browser: 'chrome',
1067
+ browser: 'chromium',
734
1068
  browserVersion: 'latest',
735
1069
  os: 'windows',
736
1070
  osVersion: '11',
@@ -1018,39 +1352,88 @@ function writeAxiosFiles(files, outputDir, silent = false) {
1018
1352
  program
1019
1353
  .command('generate:testset')
1020
1354
  .alias('generate')
1021
- .description('Generate Playwright test files from a test set')
1022
- .requiredOption('-n, --name <name>', 'Name of the test set')
1355
+ .description('Generate Playwright test files from a test set or AI-selected tests')
1356
+ .option('-n, --name <name>', 'Name of the test set')
1023
1357
  .requiredOption('-o, --output <dir>', 'Output directory for generated files')
1024
1358
  .option('-a, --api-key <key>', 'API key (or use QATE_API_KEY env var)')
1025
1359
  .option('-u, --url <url>', 'Qate API URL (or use QATE_API_URL env var)')
1026
- .option('--app <applicationId>', 'Application ID (if name is not unique)')
1360
+ .option('--app <applicationId>', 'Application ID (required for --smart, optional otherwise)')
1361
+ .option('--smart', 'Use AI to select tests based on PR changes')
1362
+ .option('--pr <number>', 'PR number (auto-detected from CI environment if not specified)')
1363
+ .option('--repo-type <type>', 'Repository type for --smart: frontend or backend', 'frontend')
1027
1364
  .option('--provider <provider>', 'Test provider: local, browserstack, saucelabs, lambdatest', 'local')
1028
1365
  .option('--orchestration <mode>', 'Orchestration mode: websocket (run from CI) or cli (provider orchestrates)', 'websocket')
1029
- .option('--browsers <browsers>', 'Comma-separated list of browsers (for local provider)', 'chromium')
1030
- .option('--browser <browser>', 'Browser for cloud providers: chrome, firefox, safari, edge', 'chrome')
1366
+ .option('--browser <browser>', 'Browser: chromium, firefox, webkit (local) or chrome, firefox, safari, edge (cloud)', 'chromium')
1031
1367
  .option('--browser-version <version>', 'Browser version for cloud providers', 'latest')
1032
1368
  .option('--os <os>', 'Operating system for cloud providers: windows, macos', 'windows')
1033
1369
  .option('--os-version <version>', 'OS version for cloud providers (e.g., 11, Sonoma)', '11')
1370
+ .option('--device <name>', 'Playwright device name for mobile emulation (e.g. "iPhone 14", "Pixel 7")')
1034
1371
  .option('--no-tracking', 'Disable result reporting to Qate')
1035
1372
  .action(async (options) => {
1036
1373
  try {
1374
+ // Validate: either --name or --smart is required
1375
+ if (!options.smart && !options.name) {
1376
+ console.error(chalk_1.default.red('Error: Either --name <test-set-name> or --smart is required.'));
1377
+ console.error(chalk_1.default.gray(' Use --name to generate from a specific test set'));
1378
+ console.error(chalk_1.default.gray(' Use --smart to let AI select tests based on PR changes'));
1379
+ process.exit(1);
1380
+ }
1381
+ if (options.smart && !options.app) {
1382
+ console.error(chalk_1.default.red('Error: --app <applicationId> is required when using --smart.'));
1383
+ console.error(chalk_1.default.gray(' Find your application ID in Qate: Settings > Applications'));
1384
+ process.exit(1);
1385
+ }
1037
1386
  const { apiKey, baseUrl } = getConfig(options);
1038
1387
  const client = new client_1.QateClient(baseUrl, apiKey);
1039
- console.log(chalk_1.default.blue(`Fetching test set: ${options.name}`));
1040
- // Create execution record for tracking (unless disabled)
1388
+ let exportData;
1041
1389
  let executionId;
1042
- if (options.tracking !== false) {
1043
- try {
1044
- const generateResult = await client.createGenerateExecution(options.name, undefined, options.app, options.provider);
1045
- executionId = generateResult.executionId;
1046
- console.log(chalk_1.default.gray(`Execution ID: ${executionId}`));
1390
+ if (options.smart) {
1391
+ // Smart generate: AI-powered test selection based on PR changes
1392
+ const prNumber = options.pr ? parseInt(options.pr) : detectPRFromEnvironment();
1393
+ if (!prNumber) {
1394
+ console.error(chalk_1.default.red('Error: Could not detect PR number from CI environment.'));
1395
+ console.error(chalk_1.default.gray(' Use --pr <number> to specify the PR number explicitly.'));
1396
+ console.error(chalk_1.default.gray(' Auto-detection supports: GitHub Actions, GitLab CI, Bitbucket Pipelines,'));
1397
+ console.error(chalk_1.default.gray(' Azure DevOps, Jenkins, CircleCI'));
1398
+ process.exit(1);
1047
1399
  }
1048
- catch (err) {
1049
- console.log(chalk_1.default.yellow('Note: Could not create execution record for tracking'));
1050
- console.log(chalk_1.default.gray(' Results will not be reported to Qate'));
1400
+ console.log(chalk_1.default.blue(`Smart generating tests for PR #${prNumber}...`));
1401
+ console.log(chalk_1.default.gray(` Application: ${options.app}`));
1402
+ console.log(chalk_1.default.gray(` Repository type: ${options.repoType}`));
1403
+ console.log(chalk_1.default.gray(' Analyzing PR changes with AI...'));
1404
+ exportData = await client.smartGenerate(options.app, prNumber, options.repoType);
1405
+ executionId = exportData.executionId;
1406
+ if (exportData.tests.length === 0) {
1407
+ console.log(chalk_1.default.yellow('\nAI analysis found no tests to execute for this PR.'));
1408
+ process.exit(0);
1409
+ }
1410
+ console.log(chalk_1.default.green(`\nAI selected ${exportData.tests.length} test(s) to execute`));
1411
+ if (executionId) {
1412
+ console.log(chalk_1.default.gray(` Execution ID: ${executionId}`));
1051
1413
  }
1052
1414
  }
1053
- const exportData = await client.exportTestSet(options.name, options.app);
1415
+ else {
1416
+ // Normal flow: generate from named test set
1417
+ console.log(chalk_1.default.blue(`Fetching test set: ${options.name}`));
1418
+ // Create execution record for tracking (unless disabled)
1419
+ if (options.tracking !== false) {
1420
+ try {
1421
+ const generateResult = await client.createGenerateExecution(options.name, undefined, options.app, options.provider, options.browser, options.device);
1422
+ executionId = generateResult.executionId;
1423
+ console.log(chalk_1.default.gray(`Execution ID: ${executionId}`));
1424
+ }
1425
+ catch (err) {
1426
+ if (err.response?.status === 429 || err.response?.status === 402) {
1427
+ const msg = err.response?.data?.message || 'Execution not allowed';
1428
+ console.error(chalk_1.default.red(msg));
1429
+ process.exit(1);
1430
+ }
1431
+ console.log(chalk_1.default.yellow('Note: Could not create execution record for tracking'));
1432
+ console.log(chalk_1.default.gray(' Results will not be reported to Qate'));
1433
+ }
1434
+ }
1435
+ exportData = await client.exportTestSet(options.name, options.app);
1436
+ }
1054
1437
  // Show summary
1055
1438
  console.log((0, PlaywrightGenerator_1.generateSummary)(exportData));
1056
1439
  // Generate files with execution ID for result reporting
@@ -1058,13 +1441,13 @@ program
1058
1441
  outputDir: options.output,
1059
1442
  provider: options.provider,
1060
1443
  orchestration: options.orchestration,
1061
- browsers: options.browsers.split(',').map((b) => b.trim()),
1062
1444
  browser: options.browser,
1063
1445
  browserVersion: options.browserVersion,
1064
1446
  os: options.os,
1065
1447
  osVersion: options.osVersion,
1066
1448
  executionId,
1067
1449
  apiUrl: baseUrl,
1450
+ device: options.device,
1068
1451
  };
1069
1452
  const files = (0, PlaywrightGenerator_1.generatePlaywrightTests)(exportData, generatorOptions);
1070
1453
  // Write files
@@ -1119,11 +1502,11 @@ program
1119
1502
  .option('--app <applicationId>', 'Application ID (if name is not unique)')
1120
1503
  .option('--provider <provider>', 'Test provider: local, browserstack, saucelabs, lambdatest', 'local')
1121
1504
  .option('--orchestration <mode>', 'Orchestration mode: websocket (run from CI) or cli (provider orchestrates)', 'websocket')
1122
- .option('--browsers <browsers>', 'Comma-separated list of browsers (for local provider)', 'chromium')
1123
- .option('--browser <browser>', 'Browser for cloud providers: chrome, firefox, safari, edge', 'chrome')
1505
+ .option('--browser <browser>', 'Browser: chromium, firefox, webkit (local) or chrome, firefox, safari, edge (cloud)', 'chromium')
1124
1506
  .option('--browser-version <version>', 'Browser version for cloud providers', 'latest')
1125
1507
  .option('--os <os>', 'Operating system for cloud providers: windows, macos', 'windows')
1126
1508
  .option('--os-version <version>', 'OS version for cloud providers (e.g., 11, Sonoma)', '11')
1509
+ .option('--device <name>', 'Playwright device name for mobile emulation (e.g. "iPhone 14", "Pixel 7")')
1127
1510
  .option('--no-tracking', 'Disable result reporting to Qate')
1128
1511
  .action(async (options) => {
1129
1512
  try {
@@ -1134,11 +1517,16 @@ program
1134
1517
  let executionId;
1135
1518
  if (options.tracking !== false) {
1136
1519
  try {
1137
- const generateResult = await client.createGenerateExecution(undefined, options.name, options.app, options.provider);
1520
+ const generateResult = await client.createGenerateExecution(undefined, options.name, options.app, options.provider, options.browser, options.device);
1138
1521
  executionId = generateResult.executionId;
1139
1522
  console.log(chalk_1.default.gray(`Execution ID: ${executionId}`));
1140
1523
  }
1141
1524
  catch (err) {
1525
+ if (err.response?.status === 429 || err.response?.status === 402) {
1526
+ const msg = err.response?.data?.message || 'Execution not allowed';
1527
+ console.error(chalk_1.default.red(msg));
1528
+ process.exit(1);
1529
+ }
1142
1530
  console.log(chalk_1.default.yellow('Note: Could not create execution record for tracking'));
1143
1531
  console.log(chalk_1.default.gray(' Results will not be reported to Qate'));
1144
1532
  }
@@ -1151,13 +1539,13 @@ program
1151
1539
  outputDir: options.output,
1152
1540
  provider: options.provider,
1153
1541
  orchestration: options.orchestration,
1154
- browsers: options.browsers.split(',').map((b) => b.trim()),
1155
1542
  browser: options.browser,
1156
1543
  browserVersion: options.browserVersion,
1157
1544
  os: options.os,
1158
1545
  osVersion: options.osVersion,
1159
1546
  executionId,
1160
1547
  apiUrl: baseUrl,
1548
+ device: options.device,
1161
1549
  };
1162
1550
  const files = (0, PlaywrightGenerator_1.generatePlaywrightTests)(exportData, generatorOptions);
1163
1551
  // Write files