qaguardian 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/README.md +168 -0
- package/bin/qag.js +6 -0
- package/package.json +35 -0
- package/src/api-client.js +195 -0
- package/src/config.js +89 -0
- package/src/index.js +189 -0
- package/src/poller.js +154 -0
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# QAG CLI - QA Guardian Command Line Interface
|
|
2
|
+
|
|
3
|
+
Trigger and monitor test suite executions from the command line.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g qaguardian
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use directly with `npx`:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx qaguardian --tags smoke
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Set your API key
|
|
21
|
+
export QAG_API_KEY=your-api-key-here
|
|
22
|
+
|
|
23
|
+
# Run suites with specific tags
|
|
24
|
+
npx qaguardian --tags auth,login
|
|
25
|
+
|
|
26
|
+
# Exclude certain tags
|
|
27
|
+
npx qaguardian --tags regression --exclude-tags slow,flaky
|
|
28
|
+
|
|
29
|
+
# Run all suites
|
|
30
|
+
npx qaguardian --all
|
|
31
|
+
|
|
32
|
+
# Fire and forget (no polling)
|
|
33
|
+
npx qaguardian --tags smoke --no-wait
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
- `--tags <tags>` - Comma-separated tags to match (e.g., "auth,ci,smoke")
|
|
39
|
+
- `--exclude-tags <tags>` - Comma-separated tags to exclude (e.g., "slow,flaky")
|
|
40
|
+
- `--all` - Run all suites (combine with `--exclude-tags` to filter)
|
|
41
|
+
- `--match-mode <mode>` - Tag matching mode: "any" (OR logic, default) or "all" (AND logic)
|
|
42
|
+
- `--no-wait` - Trigger and exit immediately without waiting for results
|
|
43
|
+
- `--webhook-url <url>` - Custom webhook URL for notifications on completion
|
|
44
|
+
- `--notify <service>` - Notify via service: slack, discord, google-chat, or teams
|
|
45
|
+
- `--api-url <url>` - Custom API Gateway URL (default: https://api.qaguardian.com)
|
|
46
|
+
|
|
47
|
+
## Environment Variables
|
|
48
|
+
|
|
49
|
+
- `QAG_API_KEY` - (Required) Your QA Guardian API key
|
|
50
|
+
- `QAG_API_URL` - (Optional) API Gateway URL (defaults to https://api.qaguardian.com)
|
|
51
|
+
|
|
52
|
+
## Examples
|
|
53
|
+
|
|
54
|
+
### Tag-based execution with wait
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
export QAG_API_KEY=guardians-primary-qag-cbaa125e027b417a
|
|
58
|
+
npx qaguardian --tags auth,ci
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Exclude certain tags
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx qaguardian --tags regression --exclude-tags slow,flaky
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Run everything except auth suites
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx qaguardian --all --exclude-tags auth
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Fire and forget
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx qaguardian --tags smoke --no-wait
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### AND logic (all tags required)
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx qaguardian --tags auth,login,ui --match-mode all
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Notifications
|
|
86
|
+
|
|
87
|
+
Receive notifications when tests complete:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Custom webhook URL
|
|
91
|
+
npx qaguardian --tags regression --webhook-url https://my-service.com/webhook
|
|
92
|
+
|
|
93
|
+
# Slack notification
|
|
94
|
+
npx qaguardian --tags smoke --notify slack
|
|
95
|
+
|
|
96
|
+
# Discord notification
|
|
97
|
+
npx qaguardian --tags auth --notify discord
|
|
98
|
+
|
|
99
|
+
# Google Chat notification
|
|
100
|
+
npx qaguardian --tags ci --notify google-chat
|
|
101
|
+
|
|
102
|
+
# Microsoft Teams notification
|
|
103
|
+
npx qaguardian --tags regression --notify teams
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Local development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
cd qag-sdk
|
|
110
|
+
npm install
|
|
111
|
+
npm link
|
|
112
|
+
export QAG_API_KEY=your-api-key
|
|
113
|
+
npx qaguardian --tags smoke --api-url http://localhost:8080
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Exit Codes
|
|
117
|
+
|
|
118
|
+
- `0` - All tests passed
|
|
119
|
+
- `1` - Any test failed or execution failed
|
|
120
|
+
|
|
121
|
+
## API Key
|
|
122
|
+
|
|
123
|
+
Get your API key from the QA Guardian dashboard at: https://app.qaguardian.com/settings/api-keys
|
|
124
|
+
|
|
125
|
+
## Polling Behavior
|
|
126
|
+
|
|
127
|
+
By default, the CLI polls the API every 30 seconds to check for completion. You can observe:
|
|
128
|
+
|
|
129
|
+
- Real-time test count updates
|
|
130
|
+
- Pass/fail counts as tests complete
|
|
131
|
+
- Automatic exit with appropriate code when all suites finish
|
|
132
|
+
|
|
133
|
+
Use `--no-wait` to skip polling and exit immediately after triggering.
|
|
134
|
+
|
|
135
|
+
## Notifications
|
|
136
|
+
|
|
137
|
+
The CLI can send notifications when test suites complete. Choose one of:
|
|
138
|
+
|
|
139
|
+
### Webhook Notifications
|
|
140
|
+
|
|
141
|
+
Send results to any HTTP endpoint:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
qag --tags regression --webhook-url https://my-service.com/webhook
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The webhook receives a POST request with execution results.
|
|
148
|
+
|
|
149
|
+
### Platform Notifications
|
|
150
|
+
|
|
151
|
+
Integrated notification support for popular chat and communication platforms:
|
|
152
|
+
|
|
153
|
+
- **Slack**: `--notify slack` (requires Slack integration configured in QA Guardian)
|
|
154
|
+
- **Discord**: `--notify discord` (requires Discord integration configured)
|
|
155
|
+
- **Google Chat**: `--notify google-chat` (requires Google Chat integration configured)
|
|
156
|
+
- **Microsoft Teams**: `--notify teams` (requires Teams integration configured)
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
qag --tags smoke --notify slack
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Notifications are sent when all tests complete, with summary of pass/fail counts.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
package/bin/qag.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qaguardian",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "QA Guardian CLI for triggering and monitoring test suite executions",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"testing",
|
|
7
|
+
"automation",
|
|
8
|
+
"playwright",
|
|
9
|
+
"ci-cd"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://gitlab.com/qaguardian/qaguardian.git"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"qaguardian": "bin/qag.js",
|
|
17
|
+
"qag": "bin/qag.js"
|
|
18
|
+
},
|
|
19
|
+
"main": "src/index.js",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20.0.0"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"commander": "^12.0.0",
|
|
26
|
+
"axios": "^1.6.7",
|
|
27
|
+
"ora": "^8.0.1",
|
|
28
|
+
"chalk": "^5.3.0"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin/",
|
|
32
|
+
"src/",
|
|
33
|
+
"README.md"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [QAG-CLI] API Client
|
|
3
|
+
* Handles HTTP communication with QA Guardian API Gateway
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import axios from 'axios';
|
|
7
|
+
|
|
8
|
+
const MAX_RETRIES = 3;
|
|
9
|
+
const RETRY_DELAY_MS = 1000;
|
|
10
|
+
const RETRY_BACKOFF = 2;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create axios instance with configuration
|
|
14
|
+
* @param {string} apiKey - API key for authentication
|
|
15
|
+
* @param {string} baseUrl - API Gateway base URL
|
|
16
|
+
* @returns {AxiosInstance} Configured axios instance
|
|
17
|
+
*/
|
|
18
|
+
function createClient(apiKey, baseUrl) {
|
|
19
|
+
return axios.create({
|
|
20
|
+
baseURL: baseUrl,
|
|
21
|
+
headers: {
|
|
22
|
+
'X-API-Key': apiKey,
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
},
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Retry logic with exponential backoff for transient failures
|
|
31
|
+
* @param {Function} fn - Async function to retry
|
|
32
|
+
* @param {number} retries - Number of retries remaining
|
|
33
|
+
* @param {number} delayMs - Current delay in milliseconds
|
|
34
|
+
* @returns {Promise<any>} Result of the function
|
|
35
|
+
*/
|
|
36
|
+
async function retryWithBackoff(fn, retries = MAX_RETRIES, delayMs = RETRY_DELAY_MS) {
|
|
37
|
+
try {
|
|
38
|
+
return await fn();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const isTransient =
|
|
41
|
+
error.code === 'ECONNREFUSED' ||
|
|
42
|
+
error.code === 'ETIMEDOUT' ||
|
|
43
|
+
error.code === 'ENOTFOUND' ||
|
|
44
|
+
(error.response && error.response.status >= 500);
|
|
45
|
+
|
|
46
|
+
if (isTransient && retries > 0) {
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
48
|
+
return retryWithBackoff(fn, retries - 1, delayMs * RETRY_BACKOFF);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Trigger flow executions by tags or run_all flag.
|
|
57
|
+
*
|
|
58
|
+
* Tenant and environment are NOT sent in the request body. The API Gateway
|
|
59
|
+
* validates the X-API-Key header and injects X-Tenant-Slug / X-Environment-Slug
|
|
60
|
+
* headers into the forwarded request; the coordinator reads them from there.
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} client - Axios client instance
|
|
63
|
+
* @param {Object} options - Execution options
|
|
64
|
+
* @param {Array<string>} options.tags - Tags to match
|
|
65
|
+
* @param {Array<string>} options.excludeTags - Tags to exclude
|
|
66
|
+
* @param {string} options.tagMatchMode - Tag matching mode ("any" or "all")
|
|
67
|
+
* @param {boolean} options.runAll - Run all flows for the tenant (skips tags requirement)
|
|
68
|
+
* @param {string} options.webhookUrl - Optional webhook URL for notifications
|
|
69
|
+
* @param {string} options.notifyService - Optional notification service (slack, discord, google-chat, teams)
|
|
70
|
+
* @returns {Promise<Object>} Execution response {triggered_flows, executions, total_triggered}
|
|
71
|
+
*/
|
|
72
|
+
export async function triggerExecution(client, options) {
|
|
73
|
+
const {
|
|
74
|
+
tags = [],
|
|
75
|
+
excludeTags = [],
|
|
76
|
+
tagMatchMode = 'any',
|
|
77
|
+
runAll = false,
|
|
78
|
+
webhookUrl,
|
|
79
|
+
notifyService,
|
|
80
|
+
} = options;
|
|
81
|
+
|
|
82
|
+
// Validate notification options
|
|
83
|
+
if (webhookUrl && notifyService) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'[QAG-CLI] Cannot use both --webhook-url and --notify. Choose one.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (notifyService) {
|
|
90
|
+
const validServices = ['slack', 'discord', 'google-chat', 'teams'];
|
|
91
|
+
if (!validServices.includes(notifyService)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`[QAG-CLI] Invalid notification service: ${notifyService}. ` +
|
|
94
|
+
`Valid options: ${validServices.join(', ')}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Require --tags unless --all is set
|
|
100
|
+
if (!runAll && tags.length === 0) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'[QAG-CLI] Must provide --tags to specify which flows to run, or use --all to run all flows.'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Build request payload. Tenant/env are omitted — the coordinator reads them
|
|
107
|
+
// from the X-Tenant-Slug / X-Environment-Slug headers injected by the API Gateway.
|
|
108
|
+
const payload = {
|
|
109
|
+
tag_match_mode: tagMatchMode,
|
|
110
|
+
tags,
|
|
111
|
+
run_all: runAll,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (excludeTags.length > 0) {
|
|
115
|
+
payload.exclude_tags = excludeTags;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Add notification config if provided
|
|
119
|
+
if (webhookUrl || notifyService) {
|
|
120
|
+
payload.notification_config = {};
|
|
121
|
+
|
|
122
|
+
if (webhookUrl) {
|
|
123
|
+
payload.notification_config.webhook_url = webhookUrl;
|
|
124
|
+
payload.notification_config.webhook_enabled = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (notifyService) {
|
|
128
|
+
payload.notification_config.notify_service = notifyService;
|
|
129
|
+
payload.notification_config.service_enabled = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const response = await retryWithBackoff(() =>
|
|
135
|
+
client.post('/api/v1/flows/executions', payload)
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return response.data;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error.response?.status === 401) {
|
|
141
|
+
throw new Error('[QAG-CLI] Authentication failed. Check your QAG_API_KEY.');
|
|
142
|
+
}
|
|
143
|
+
if (error.response?.status === 404) {
|
|
144
|
+
throw new Error('[QAG-CLI] API endpoint not found. Check QAG_API_URL.');
|
|
145
|
+
}
|
|
146
|
+
if (error.response?.data?.detail) {
|
|
147
|
+
const detail = error.response.data.detail;
|
|
148
|
+
const detailStr = typeof detail === 'string' ? detail : JSON.stringify(detail);
|
|
149
|
+
throw new Error(`[QAG-CLI] Server error: ${detailStr}`);
|
|
150
|
+
}
|
|
151
|
+
throw new Error(
|
|
152
|
+
`[QAG-CLI] Failed to trigger executions: ${error.message}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get execution status for a flow run
|
|
159
|
+
* @param {Object} client - Axios client instance
|
|
160
|
+
* @param {string} flowRunId - Flow run ID to poll
|
|
161
|
+
* @returns {Promise<Object>} Execution status object
|
|
162
|
+
*/
|
|
163
|
+
export async function getExecutionStatus(client, flowRunId) {
|
|
164
|
+
try {
|
|
165
|
+
const response = await retryWithBackoff(() =>
|
|
166
|
+
client.get(`/api/v1/flows/executions/${flowRunId}`)
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return response.data;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error.response?.status === 404) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`[QAG-CLI] Flow run ${flowRunId} not found. It may have been cancelled.`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (error.response?.data?.detail) {
|
|
177
|
+
const detail = error.response.data.detail;
|
|
178
|
+
const detailStr = typeof detail === 'string' ? detail : JSON.stringify(detail);
|
|
179
|
+
throw new Error(`[QAG-CLI] Server error: ${detailStr}`);
|
|
180
|
+
}
|
|
181
|
+
throw new Error(
|
|
182
|
+
`[QAG-CLI] Failed to get execution status: ${error.message}`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create and return configured API client
|
|
189
|
+
* @param {string} apiKey - API key for authentication
|
|
190
|
+
* @param {string} baseUrl - API Gateway base URL
|
|
191
|
+
* @returns {AxiosInstance} Configured axios instance
|
|
192
|
+
*/
|
|
193
|
+
export function createApiClient(apiKey, baseUrl) {
|
|
194
|
+
return createClient(apiKey, baseUrl);
|
|
195
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [QAG-CLI] Config module
|
|
3
|
+
* Validates environment variables and defaults
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_API_URL = 'https://api.qaguardian.com';
|
|
7
|
+
const DEFAULT_POLL_INTERVAL_MS = 30000; // 30 seconds
|
|
8
|
+
const POLL_TIMEOUT_MS = 3600000; // 1 hour max polling time
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate and load configuration
|
|
12
|
+
* @returns {Object} Configuration object
|
|
13
|
+
* @throws {Error} If required config is missing
|
|
14
|
+
*/
|
|
15
|
+
export function loadConfig() {
|
|
16
|
+
const apiKey = process.env.QAG_API_KEY;
|
|
17
|
+
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'[QAG-CLI] QAG_API_KEY environment variable is required. ' +
|
|
21
|
+
'Get your API key from https://app.qaguardian.com/settings/api-keys'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
apiKey,
|
|
27
|
+
apiUrl: process.env.QAG_API_URL || DEFAULT_API_URL,
|
|
28
|
+
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
29
|
+
pollTimeoutMs: POLL_TIMEOUT_MS,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse comma-separated tag string into array
|
|
35
|
+
* @param {string} tagString - Comma-separated tags (e.g. "auth,ci,smoke")
|
|
36
|
+
* @returns {Array<string>} Array of trimmed tags
|
|
37
|
+
*/
|
|
38
|
+
export function parseTags(tagString) {
|
|
39
|
+
if (!tagString) return [];
|
|
40
|
+
return tagString
|
|
41
|
+
.split(',')
|
|
42
|
+
.map(tag => tag.trim())
|
|
43
|
+
.filter(tag => tag.length > 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse API key to extract tenant and environment slugs
|
|
48
|
+
* Format: {tenant_slug}-{environment_slug}-qag-{hash}
|
|
49
|
+
* Example: guardians-primary-qag-cbaa125e027b417a
|
|
50
|
+
* @param {string} apiKey - API key to parse
|
|
51
|
+
* @returns {{tenant_slug: string, environment_slug: string}|null}
|
|
52
|
+
*/
|
|
53
|
+
export function parseApiKey(apiKey) {
|
|
54
|
+
if (!apiKey) return null;
|
|
55
|
+
|
|
56
|
+
// Split on '-qag-' to isolate the hash suffix
|
|
57
|
+
const parts = apiKey.split('-qag-');
|
|
58
|
+
if (parts.length !== 2) return null;
|
|
59
|
+
|
|
60
|
+
const hash = parts[1];
|
|
61
|
+
if (!/^[a-f0-9]+$/.test(hash)) return null;
|
|
62
|
+
|
|
63
|
+
// The prefix is '{tenant_slug}-{environment_slug}'
|
|
64
|
+
// Use the last hyphen to split, so multi-segment tenant slugs work
|
|
65
|
+
const prefix = parts[0];
|
|
66
|
+
const lastDash = prefix.lastIndexOf('-');
|
|
67
|
+
if (lastDash === -1) return null;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
tenant_slug: prefix.substring(0, lastDash),
|
|
71
|
+
environment_slug: prefix.substring(lastDash + 1),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate tag matching mode
|
|
77
|
+
* @param {string} mode - Matching mode ("any" or "all")
|
|
78
|
+
* @returns {string} Validated mode
|
|
79
|
+
* @throws {Error} If mode is invalid
|
|
80
|
+
*/
|
|
81
|
+
export function validateTagMatchMode(mode) {
|
|
82
|
+
const valid = ['any', 'all'];
|
|
83
|
+
if (!valid.includes(mode)) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`[QAG-CLI] Invalid tag match mode: ${mode}. Must be "any" (OR) or "all" (AND).`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return mode;
|
|
89
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [QAG-CLI] Main CLI Entry Point
|
|
3
|
+
* Command-line interface for triggering and monitoring QA Guardian test executions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'module';
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { loadConfig, parseTags, validateTagMatchMode, parseApiKey } from './config.js';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const { version } = require('../package.json');
|
|
13
|
+
import { createApiClient, triggerExecution } from './api-client.js';
|
|
14
|
+
import { pollUntilComplete } from './poller.js';
|
|
15
|
+
|
|
16
|
+
const program = new Command();
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name('qag')
|
|
20
|
+
.description('QA Guardian CLI - Trigger and monitor test suite executions')
|
|
21
|
+
.version(version)
|
|
22
|
+
.usage('[options]')
|
|
23
|
+
.option(
|
|
24
|
+
'--tags <tags>',
|
|
25
|
+
'Comma-separated tags to match (e.g., "auth,ci,smoke")'
|
|
26
|
+
)
|
|
27
|
+
.option(
|
|
28
|
+
'--exclude-tags <tags>',
|
|
29
|
+
'Comma-separated tags to exclude (e.g., "slow,flaky")'
|
|
30
|
+
)
|
|
31
|
+
.option(
|
|
32
|
+
'--all',
|
|
33
|
+
'Run all flows (combine with --exclude-tags to filter)'
|
|
34
|
+
)
|
|
35
|
+
.option(
|
|
36
|
+
'--match-mode <mode>',
|
|
37
|
+
'Tag matching mode: "any" (OR logic, default) or "all" (AND logic)',
|
|
38
|
+
'any'
|
|
39
|
+
)
|
|
40
|
+
.option(
|
|
41
|
+
'--no-wait',
|
|
42
|
+
'Trigger and exit immediately without waiting for results'
|
|
43
|
+
)
|
|
44
|
+
.option(
|
|
45
|
+
'--webhook-url <url>',
|
|
46
|
+
'Custom webhook URL for completion notifications'
|
|
47
|
+
)
|
|
48
|
+
.option(
|
|
49
|
+
'--notify <service>',
|
|
50
|
+
'Notify via service: slack, discord, google-chat, or teams'
|
|
51
|
+
)
|
|
52
|
+
.option(
|
|
53
|
+
'--api-url <url>',
|
|
54
|
+
'Custom API Gateway URL (default: https://api.qaguardian.com)',
|
|
55
|
+
'https://api.qaguardian.com'
|
|
56
|
+
)
|
|
57
|
+
.action(runCLI);
|
|
58
|
+
|
|
59
|
+
program.parse();
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Main CLI execution handler
|
|
63
|
+
* @param {Object} options - Parsed CLI options
|
|
64
|
+
*/
|
|
65
|
+
async function runCLI(options) {
|
|
66
|
+
try {
|
|
67
|
+
// Load and validate configuration
|
|
68
|
+
const config = loadConfig();
|
|
69
|
+
const client = createApiClient(config.apiKey, options.apiUrl);
|
|
70
|
+
|
|
71
|
+
// Parse and validate options
|
|
72
|
+
const tags = parseTags(options.tags || '');
|
|
73
|
+
const excludeTags = parseTags(options.excludeTags || '');
|
|
74
|
+
const tagMatchMode = validateTagMatchMode(options.matchMode);
|
|
75
|
+
const runAll = options.all || false;
|
|
76
|
+
const wait = options.wait; // true by default, false with --no-wait
|
|
77
|
+
const apiUrl = options.apiUrl;
|
|
78
|
+
|
|
79
|
+
// Extract tenant and environment from API key
|
|
80
|
+
const apiKeyInfo = parseApiKey(config.apiKey);
|
|
81
|
+
if (!apiKeyInfo) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'[QAG-CLI] Invalid API key format. Expected: {tenant_slug}-{environment_slug}-qag-{hash}'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { tenant_slug, environment_slug } = apiKeyInfo;
|
|
88
|
+
|
|
89
|
+
console.log(
|
|
90
|
+
chalk.blue('[QAG-CLI]'),
|
|
91
|
+
'Triggering flow executions...'
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (runAll && excludeTags.length > 0) {
|
|
95
|
+
console.log(chalk.gray(' Excluding tags:'), excludeTags.join(', '));
|
|
96
|
+
} else if (tags.length > 0) {
|
|
97
|
+
console.log(chalk.gray(' Tags:'), tags.join(', '));
|
|
98
|
+
if (excludeTags.length > 0) {
|
|
99
|
+
console.log(chalk.gray(' Excluding:'), excludeTags.join(', '));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (options.webhookUrl) {
|
|
104
|
+
console.log(chalk.gray(' Webhook:'), options.webhookUrl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (options.notify) {
|
|
108
|
+
console.log(chalk.gray(' Notify via:'), options.notify);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Validate that we have tags
|
|
112
|
+
if (!runAll && tags.length === 0) {
|
|
113
|
+
throw new Error('[QAG-CLI] Must provide --tags to specify which flows to run');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Trigger execution
|
|
117
|
+
const response = await triggerExecution(client, {
|
|
118
|
+
tags,
|
|
119
|
+
excludeTags,
|
|
120
|
+
tagMatchMode,
|
|
121
|
+
runAll,
|
|
122
|
+
tenantSlug: tenant_slug,
|
|
123
|
+
environmentName: environment_slug,
|
|
124
|
+
webhookUrl: options.webhookUrl,
|
|
125
|
+
notifyService: options.notify,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const { triggered_flows: flowRunIds, executions, total_triggered: totalTriggered } = response;
|
|
129
|
+
|
|
130
|
+
if (!flowRunIds || flowRunIds.length === 0) {
|
|
131
|
+
console.log(chalk.yellow('⚠ No flows matched the specified criteria'));
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(
|
|
136
|
+
chalk.green('✓'),
|
|
137
|
+
`Triggered ${totalTriggered} flow run(s):`,
|
|
138
|
+
flowRunIds.join(', ')
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// If --no-wait, exit immediately
|
|
142
|
+
if (!wait) {
|
|
143
|
+
console.log(
|
|
144
|
+
chalk.blue('[QAG-CLI]'),
|
|
145
|
+
'Not waiting for completion (--no-wait flag set)'
|
|
146
|
+
);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Poll for completion
|
|
151
|
+
console.log(chalk.blue('[QAG-CLI]') + ' Polling for results (30s intervals)...');
|
|
152
|
+
const result = await pollUntilComplete(client, flowRunIds, {
|
|
153
|
+
pollIntervalMs: 30000,
|
|
154
|
+
pollTimeoutMs: 3600000,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Print final summary
|
|
158
|
+
const { summary } = result;
|
|
159
|
+
console.log();
|
|
160
|
+
console.log(chalk.blue('═══════════════════════════════════'));
|
|
161
|
+
console.log(chalk.blue('[QAG-CLI] Execution Summary:'));
|
|
162
|
+
console.log(chalk.blue('═══════════════════════════════════'));
|
|
163
|
+
console.log(` Flow Runs: ${summary.flowRuns}`);
|
|
164
|
+
console.log(` Total Tests: ${summary.totalTests}`);
|
|
165
|
+
console.log(
|
|
166
|
+
` ${chalk.green('Passed:')} ${summary.passed}`
|
|
167
|
+
);
|
|
168
|
+
console.log(
|
|
169
|
+
` ${chalk.red('Failed:')} ${summary.failed}`
|
|
170
|
+
);
|
|
171
|
+
console.log(chalk.blue('═══════════════════════════════════'));
|
|
172
|
+
|
|
173
|
+
// Exit with appropriate code
|
|
174
|
+
if (summary.hasFailures) {
|
|
175
|
+
console.log(
|
|
176
|
+
chalk.red('✗ Tests completed with failures')
|
|
177
|
+
);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
} else {
|
|
180
|
+
console.log(
|
|
181
|
+
chalk.green('✓ All tests passed!')
|
|
182
|
+
);
|
|
183
|
+
process.exit(0);
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error(chalk.red(error.message || 'Unknown error'));
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/poller.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [QAG-CLI] Polling Module
|
|
3
|
+
* Handles real-time polling of flow executions with progress display
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import { getExecutionStatus } from './api-client.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Poll flow runs until completion
|
|
11
|
+
* @param {Object} client - Axios client instance
|
|
12
|
+
* @param {Array<string>} flowRunIds - Flow run IDs to monitor
|
|
13
|
+
* @param {Object} config - Configuration {pollIntervalMs, pollTimeoutMs}
|
|
14
|
+
* @returns {Promise<Object>} Final execution results
|
|
15
|
+
*/
|
|
16
|
+
export async function pollUntilComplete(client, flowRunIds, config) {
|
|
17
|
+
if (!flowRunIds || flowRunIds.length === 0) {
|
|
18
|
+
throw new Error('[QAG-CLI] No flow runs to poll');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { pollIntervalMs = 30000, pollTimeoutMs = 3600000 } = config;
|
|
22
|
+
|
|
23
|
+
const spinner = ora({
|
|
24
|
+
text: `[QAG-CLI] Polling ${flowRunIds.length} flow run(s)...`,
|
|
25
|
+
prefixText: '',
|
|
26
|
+
spinner: 'dots',
|
|
27
|
+
}).start();
|
|
28
|
+
|
|
29
|
+
const startTime = Date.now();
|
|
30
|
+
const statuses = new Map(); // Track individual run statuses
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
while (true) {
|
|
34
|
+
// Check for timeout
|
|
35
|
+
if (Date.now() - startTime > pollTimeoutMs) {
|
|
36
|
+
spinner.fail('[QAG-CLI] Polling timeout reached (1 hour)');
|
|
37
|
+
return createFailureResult(statuses);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Poll all flow runs in parallel
|
|
41
|
+
const pollPromises = flowRunIds.map(id =>
|
|
42
|
+
getExecutionStatus(client, id)
|
|
43
|
+
.then(status => ({ id, status, error: null }))
|
|
44
|
+
.catch(error => ({ id, status: null, error }))
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const results = await Promise.all(pollPromises);
|
|
48
|
+
|
|
49
|
+
// Update statuses map
|
|
50
|
+
let allComplete = true;
|
|
51
|
+
let totalTests = 0;
|
|
52
|
+
let completedTests = 0;
|
|
53
|
+
let passedTests = 0;
|
|
54
|
+
let failedTests = 0;
|
|
55
|
+
|
|
56
|
+
for (const result of results) {
|
|
57
|
+
if (result.error) {
|
|
58
|
+
statuses.set(result.id, { status: 'error', error: result.error.message });
|
|
59
|
+
allComplete = false;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { status } = result.status;
|
|
64
|
+
statuses.set(result.id, result.status);
|
|
65
|
+
|
|
66
|
+
// Aggregate stats
|
|
67
|
+
totalTests += result.status.total_tests || 0;
|
|
68
|
+
completedTests += result.status.completed_tests || 0;
|
|
69
|
+
failedTests += result.status.failed_tests || 0;
|
|
70
|
+
|
|
71
|
+
if (status === 'starting' || status === 'running' || status === 'pending') {
|
|
72
|
+
allComplete = false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Calculate passed from completed and failed
|
|
77
|
+
passedTests = completedTests - failedTests;
|
|
78
|
+
|
|
79
|
+
// Update spinner text with progress
|
|
80
|
+
const progressText = `Running... (${completedTests}/${totalTests} tests completed, ${failedTests} failed)`;
|
|
81
|
+
spinner.text = progressText;
|
|
82
|
+
|
|
83
|
+
// Check if all runs are complete
|
|
84
|
+
if (allComplete) {
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Wait before next poll
|
|
89
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
spinner.succeed('[QAG-CLI] All flow runs completed');
|
|
93
|
+
return createSuccessResult(statuses);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
spinner.fail(`[QAG-CLI] Error during polling: ${error.message}`);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create success result object
|
|
102
|
+
* @param {Map} statuses - Flow run statuses
|
|
103
|
+
* @returns {Object} Result with exit code and summary
|
|
104
|
+
*/
|
|
105
|
+
function createSuccessResult(statuses) {
|
|
106
|
+
let hasFailures = false;
|
|
107
|
+
let totalTests = 0;
|
|
108
|
+
let totalPassed = 0;
|
|
109
|
+
let totalFailed = 0;
|
|
110
|
+
|
|
111
|
+
for (const [id, status] of statuses.entries()) {
|
|
112
|
+
if (status.error) {
|
|
113
|
+
hasFailures = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
totalTests += status.total_tests || 0;
|
|
118
|
+
totalPassed += (status.total_tests || 0) - (status.failed_tests || 0);
|
|
119
|
+
totalFailed += status.failed_tests || 0;
|
|
120
|
+
|
|
121
|
+
if (status.status === 'failed' || status.failed_tests > 0) {
|
|
122
|
+
hasFailures = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
exitCode: hasFailures ? 1 : 0,
|
|
128
|
+
summary: {
|
|
129
|
+
totalTests,
|
|
130
|
+
passed: totalPassed,
|
|
131
|
+
failed: totalFailed,
|
|
132
|
+
flowRuns: statuses.size,
|
|
133
|
+
hasFailures,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create failure result object
|
|
140
|
+
* @param {Map} statuses - Flow run statuses
|
|
141
|
+
* @returns {Object} Result with exit code and summary
|
|
142
|
+
*/
|
|
143
|
+
function createFailureResult(statuses) {
|
|
144
|
+
return {
|
|
145
|
+
exitCode: 1,
|
|
146
|
+
summary: {
|
|
147
|
+
totalTests: 0,
|
|
148
|
+
passed: 0,
|
|
149
|
+
failed: 0,
|
|
150
|
+
flowRuns: statuses.size,
|
|
151
|
+
hasFailures: true,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|