qatools-cli 1.0.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/package.json +15 -0
- package/qatools.js +604 -0
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qatools-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI for QA Tools - read test sessions, upload Claude analysis",
|
|
5
|
+
"bin": {
|
|
6
|
+
"qatools": "./qatools.js"
|
|
7
|
+
},
|
|
8
|
+
"files": ["qatools.js"],
|
|
9
|
+
"keywords": ["qa", "testing", "claude", "ai", "test-management"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"firebase": "^11.0.0",
|
|
13
|
+
"commander": "^12.0.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/qatools.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require("commander");
|
|
4
|
+
const { initializeApp } = require("firebase/app");
|
|
5
|
+
const { getAuth, signInWithEmailAndPassword, signInWithCustomToken } = require("firebase/auth");
|
|
6
|
+
const { getFunctions, httpsCallable, connectFunctionsEmulator } = require("firebase/functions");
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const http = require("http");
|
|
10
|
+
|
|
11
|
+
const firebaseConfig = {
|
|
12
|
+
projectId: "qatools-app",
|
|
13
|
+
appId: "1:818706206429:web:647fe512fdba662cb4d534",
|
|
14
|
+
storageBucket: "qatools-app.firebasestorage.app",
|
|
15
|
+
apiKey: "AIzaSyAh-wQgQ7g0aEDnriEPmpS13VqeyKznFHw",
|
|
16
|
+
authDomain: "qatools-app.firebaseapp.com",
|
|
17
|
+
messagingSenderId: "818706206429",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const app = initializeApp(firebaseConfig);
|
|
21
|
+
const auth = getAuth(app);
|
|
22
|
+
const functions = getFunctions(app);
|
|
23
|
+
|
|
24
|
+
// Load credentials from ~/.qatools or env vars
|
|
25
|
+
function getCredentials() {
|
|
26
|
+
const configPath = path.join(process.env.HOME || process.env.USERPROFILE, ".qatools");
|
|
27
|
+
if (fs.existsSync(configPath)) {
|
|
28
|
+
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
29
|
+
}
|
|
30
|
+
if (process.env.QATOOLS_EMAIL && process.env.QATOOLS_PASSWORD) {
|
|
31
|
+
return { email: process.env.QATOOLS_EMAIL, password: process.env.QATOOLS_PASSWORD };
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function authenticate(options) {
|
|
37
|
+
const creds = getCredentials();
|
|
38
|
+
const email = options.email || creds?.email;
|
|
39
|
+
const password = options.password || creds?.password;
|
|
40
|
+
const refreshToken = creds?.refreshToken;
|
|
41
|
+
|
|
42
|
+
if (refreshToken) {
|
|
43
|
+
try {
|
|
44
|
+
// Exchange refresh token for a new ID token, then sign in
|
|
45
|
+
const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${firebaseConfig.apiKey}`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
48
|
+
body: `grant_type=refresh_token&refresh_token=${refreshToken}`,
|
|
49
|
+
});
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
if (!data.id_token) throw new Error("Refresh failed");
|
|
52
|
+
// Use the auth emulator approach: sign in with custom token from server
|
|
53
|
+
// Actually we need to use signInWithCustomToken, so mint one
|
|
54
|
+
const mintRes = await fetch(`https://us-central1-qatools-app.cloudfunctions.net/mintCliToken`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${data.id_token}` },
|
|
57
|
+
body: JSON.stringify({ data: {} }),
|
|
58
|
+
});
|
|
59
|
+
const mintData = await mintRes.json();
|
|
60
|
+
if (mintData.result?.customToken) {
|
|
61
|
+
await signInWithCustomToken(auth, mintData.result.customToken);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
throw new Error("Mint failed");
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error("Google token refresh failed:", e.message, "\nRun 'qatools login --google' again.");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!email || !password) {
|
|
72
|
+
console.error("Error: No credentials. Run 'qatools login --google' or 'qatools login -e <email> -p <password>'");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
await signInWithEmailAndPassword(auth, email, password);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error("Authentication failed:", e.message);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
program
|
|
84
|
+
.name("qatools")
|
|
85
|
+
.description("CLI for QA Tools")
|
|
86
|
+
.version("1.0.0");
|
|
87
|
+
|
|
88
|
+
program
|
|
89
|
+
.command("upload-analysis")
|
|
90
|
+
.description("Upload a Claude analysis JSON to a test session")
|
|
91
|
+
.requiredOption("-s, --session <id>", "Test session ID")
|
|
92
|
+
.requiredOption("-f, --file <path>", "Path to analysis JSON file")
|
|
93
|
+
.option("-e, --email <email>", "Login email")
|
|
94
|
+
.option("-p, --password <password>", "Login password")
|
|
95
|
+
.action(async (options) => {
|
|
96
|
+
await authenticate(options);
|
|
97
|
+
|
|
98
|
+
const filePath = path.resolve(options.file);
|
|
99
|
+
if (!fs.existsSync(filePath)) {
|
|
100
|
+
console.error(`File not found: ${filePath}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
105
|
+
const analysisArray = data.analysis || [data];
|
|
106
|
+
const backlogUpdates = data.backlogUpdates || {};
|
|
107
|
+
|
|
108
|
+
console.log(`Uploading ${analysisArray.length} analysis/analyses to session ${options.session}...`);
|
|
109
|
+
|
|
110
|
+
const uploadAnalysis = httpsCallable(functions, "uploadAnalysis");
|
|
111
|
+
try {
|
|
112
|
+
const result = await uploadAnalysis({
|
|
113
|
+
sessionId: options.session,
|
|
114
|
+
analysis: analysisArray,
|
|
115
|
+
backlogUpdates,
|
|
116
|
+
});
|
|
117
|
+
console.log("Success!");
|
|
118
|
+
console.log(` Analyses on session: ${result.data.analysesCount}`);
|
|
119
|
+
console.log(` Backlog items updated: ${result.data.backlogItemsUpdated}`);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error("Upload failed:", e.message);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
process.exit(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
program
|
|
128
|
+
.command("read-session")
|
|
129
|
+
.description("Read a test session with template and backlog details")
|
|
130
|
+
.requiredOption("-s, --session <id>", "Test session ID")
|
|
131
|
+
.option("-o, --output <path>", "Output file (default: stdout)")
|
|
132
|
+
.option("-e, --email <email>", "Login email")
|
|
133
|
+
.option("-p, --password <password>", "Login password")
|
|
134
|
+
.action(async (options) => {
|
|
135
|
+
await authenticate(options);
|
|
136
|
+
|
|
137
|
+
console.error(`Reading session ${options.session}...`);
|
|
138
|
+
|
|
139
|
+
const readSession = httpsCallable(functions, "readSession");
|
|
140
|
+
try {
|
|
141
|
+
const result = await readSession({ sessionId: options.session });
|
|
142
|
+
const json = JSON.stringify(result.data, null, 2);
|
|
143
|
+
if (options.output) {
|
|
144
|
+
fs.writeFileSync(path.resolve(options.output), json);
|
|
145
|
+
console.error(`Written to ${options.output}`);
|
|
146
|
+
} else {
|
|
147
|
+
console.log(json);
|
|
148
|
+
}
|
|
149
|
+
} catch (e) {
|
|
150
|
+
console.error("Read failed:", e.message);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
process.exit(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
program
|
|
157
|
+
.command("setup")
|
|
158
|
+
.description("Select default project (login first with 'qatools login')")
|
|
159
|
+
.option("-e, --email <email>", "Login email")
|
|
160
|
+
.option("-p, --password <password>", "Login password")
|
|
161
|
+
.action(async (options) => {
|
|
162
|
+
await authenticate(options);
|
|
163
|
+
console.log("Fetching your projects...\n");
|
|
164
|
+
|
|
165
|
+
const listProjects = httpsCallable(functions, "listProjects");
|
|
166
|
+
const result = await listProjects({});
|
|
167
|
+
const projects = result.data.projects;
|
|
168
|
+
|
|
169
|
+
if (projects.length === 0) {
|
|
170
|
+
console.error("No projects found for this account.");
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log("Your projects:");
|
|
175
|
+
projects.forEach((p, i) => console.log(` ${i + 1}. ${p.name} ${p.claudeEnabled ? '🤖' : ''} (${p.id})`));
|
|
176
|
+
|
|
177
|
+
// Use readline for selection
|
|
178
|
+
const readline = require("readline");
|
|
179
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
180
|
+
const answer = await new Promise(resolve => rl.question(`\nSelect project (1-${projects.length}): `, resolve));
|
|
181
|
+
rl.close();
|
|
182
|
+
|
|
183
|
+
const idx = parseInt(answer, 10) - 1;
|
|
184
|
+
if (idx < 0 || idx >= projects.length) {
|
|
185
|
+
console.error("Invalid selection.");
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const selected = projects[idx];
|
|
190
|
+
const configPath = path.join(process.env.HOME || process.env.USERPROFILE, ".qatools");
|
|
191
|
+
// Preserve existing credentials
|
|
192
|
+
let existing = {};
|
|
193
|
+
if (fs.existsSync(configPath)) {
|
|
194
|
+
try { existing = JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch {}
|
|
195
|
+
}
|
|
196
|
+
const config = { ...existing, projectId: selected.id, projectName: selected.name };
|
|
197
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
198
|
+
console.log(`\nSaved! Default project: ${selected.name} (${selected.id})`);
|
|
199
|
+
console.log(`Config: ${configPath}`);
|
|
200
|
+
process.exit(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
program
|
|
204
|
+
.command("pending")
|
|
205
|
+
.description("List test sessions awaiting Claude analysis")
|
|
206
|
+
.option("-e, --email <email>", "Login email")
|
|
207
|
+
.option("-p, --password <password>", "Login password")
|
|
208
|
+
.action(async (options) => {
|
|
209
|
+
await authenticate(options);
|
|
210
|
+
const creds = getCredentials();
|
|
211
|
+
const projectId = creds?.projectId;
|
|
212
|
+
|
|
213
|
+
const pendingSessions = httpsCallable(functions, "pendingSessions");
|
|
214
|
+
try {
|
|
215
|
+
const result = await pendingSessions({ projectId: projectId || null });
|
|
216
|
+
const pending = result.data.pending;
|
|
217
|
+
if (pending.length === 0) {
|
|
218
|
+
console.log("No sessions awaiting analysis.");
|
|
219
|
+
} else {
|
|
220
|
+
console.log(`${pending.length} session(s) awaiting analysis:\n`);
|
|
221
|
+
for (const s of pending) {
|
|
222
|
+
console.log(` Session: ${s.sessionId}`);
|
|
223
|
+
console.log(` Template: ${s.templateName}`);
|
|
224
|
+
console.log(` Tester: ${s.tester}`);
|
|
225
|
+
console.log(` Date: ${s.testDate}`);
|
|
226
|
+
console.log(` Failures: ${s.failCount}/${s.totalTests}`);
|
|
227
|
+
console.log("");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch (e) {
|
|
231
|
+
console.error("Failed:", e.message);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
process.exit(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
program
|
|
238
|
+
.command("backlog")
|
|
239
|
+
.description("List backlog items for configured project")
|
|
240
|
+
.option("--sprint <id>", "Filter by sprint ID")
|
|
241
|
+
.option("--status <statuses>", "Filter by status (comma-separated: new,in_progress,on_hold)")
|
|
242
|
+
.option("--assigned <email>", "Filter by assignee")
|
|
243
|
+
.option("-o, --output <path>", "Output file (default: stdout)")
|
|
244
|
+
.option("-e, --email <email>", "Login email")
|
|
245
|
+
.option("-p, --password <password>", "Login password")
|
|
246
|
+
.action(async (options) => {
|
|
247
|
+
await authenticate(options);
|
|
248
|
+
const creds = getCredentials();
|
|
249
|
+
if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
|
|
250
|
+
|
|
251
|
+
const listBacklog = httpsCallable(functions, "listBacklog");
|
|
252
|
+
try {
|
|
253
|
+
const filters = { projectId: creds.projectId };
|
|
254
|
+
if (options.sprint) filters.sprintId = options.sprint;
|
|
255
|
+
if (options.status) filters.status = options.status.split(",");
|
|
256
|
+
if (options.assigned) filters.assignedTo = options.assigned;
|
|
257
|
+
const result = await listBacklog(filters);
|
|
258
|
+
const data = result.data;
|
|
259
|
+
|
|
260
|
+
if (options.output) {
|
|
261
|
+
fs.writeFileSync(path.resolve(options.output), JSON.stringify(data, null, 2));
|
|
262
|
+
console.error(`Written ${data.items.length} items to ${options.output}`);
|
|
263
|
+
} else {
|
|
264
|
+
console.log(JSON.stringify(data, null, 2));
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error("Failed:", e.message);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
process.exit(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
program
|
|
274
|
+
.command("sprint-items")
|
|
275
|
+
.description("List all items in a sprint")
|
|
276
|
+
.requiredOption("--sprint <id>", "Sprint ID")
|
|
277
|
+
.option("-o, --output <path>", "Output file")
|
|
278
|
+
.option("-e, --email <email>", "Login email")
|
|
279
|
+
.option("-p, --password <password>", "Login password")
|
|
280
|
+
.action(async (options) => {
|
|
281
|
+
await authenticate(options);
|
|
282
|
+
const creds = getCredentials();
|
|
283
|
+
if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
|
|
284
|
+
|
|
285
|
+
const getSprintItems = httpsCallable(functions, "getSprintItems");
|
|
286
|
+
try {
|
|
287
|
+
const result = await getSprintItems({ projectId: creds.projectId, sprintId: options.sprint });
|
|
288
|
+
const data = result.data;
|
|
289
|
+
if (options.output) {
|
|
290
|
+
fs.writeFileSync(path.resolve(options.output), JSON.stringify(data, null, 2));
|
|
291
|
+
console.error(`Sprint: ${data.sprint?.name || "unknown"} — ${data.items.length} items`);
|
|
292
|
+
} else {
|
|
293
|
+
console.log(JSON.stringify(data, null, 2));
|
|
294
|
+
}
|
|
295
|
+
} catch (e) {
|
|
296
|
+
console.error("Failed:", e.message);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
process.exit(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
program
|
|
303
|
+
.command("sprints")
|
|
304
|
+
.description("List sprints for configured project")
|
|
305
|
+
.option("-e, --email <email>", "Login email")
|
|
306
|
+
.option("-p, --password <password>", "Login password")
|
|
307
|
+
.action(async (options) => {
|
|
308
|
+
await authenticate(options);
|
|
309
|
+
const creds = getCredentials();
|
|
310
|
+
if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
|
|
311
|
+
|
|
312
|
+
const listSprints = httpsCallable(functions, "listSprints");
|
|
313
|
+
try {
|
|
314
|
+
const result = await listSprints({ projectId: creds.projectId });
|
|
315
|
+
for (const s of result.data.sprints) {
|
|
316
|
+
console.log(` ${s.id} ${s.name} [${s.status}] Release: ${s.releaseDate || "N/A"}`);
|
|
317
|
+
}
|
|
318
|
+
} catch (e) {
|
|
319
|
+
console.error("Failed:", e.message);
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
process.exit(0);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
program
|
|
326
|
+
.command("backlog-create")
|
|
327
|
+
.description("Create a new backlog item")
|
|
328
|
+
.requiredOption("-d, --description <text>", "Description")
|
|
329
|
+
.option("-t, --type <type>", "Type: bug, task, improvement, feature, epic, story", "task")
|
|
330
|
+
.option("--priority <n>", "Priority: 1 (high), 2 (medium), 3 (low)", "2")
|
|
331
|
+
.option("--assigned <email>", "Assign to")
|
|
332
|
+
.option("--sprint <id>", "Sprint ID")
|
|
333
|
+
.option("--parent <label>", "Parent item label (e.g. STORY-5)")
|
|
334
|
+
.option("--requirement-key <key>", "Requirement key (e.g. auth.login)")
|
|
335
|
+
.option("--tags <tags>", "Tags (comma-separated)")
|
|
336
|
+
.option("-e, --email <email>", "Login email")
|
|
337
|
+
.option("-p, --password <password>", "Login password")
|
|
338
|
+
.action(async (options) => {
|
|
339
|
+
await authenticate(options);
|
|
340
|
+
const creds = getCredentials();
|
|
341
|
+
if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
|
|
342
|
+
|
|
343
|
+
const createItem = httpsCallable(functions, "createBacklogItem");
|
|
344
|
+
try {
|
|
345
|
+
const result = await createItem({
|
|
346
|
+
projectId: creds.projectId,
|
|
347
|
+
type: options.type,
|
|
348
|
+
description: options.description,
|
|
349
|
+
priority: parseInt(options.priority, 10),
|
|
350
|
+
assignedTo: options.assigned || "",
|
|
351
|
+
sprintId: options.sprint || "",
|
|
352
|
+
parentId: options.parent || "",
|
|
353
|
+
requirementKey: options.requirementKey || "",
|
|
354
|
+
tags: options.tags ? options.tags.split(",").map(t => t.trim()) : [],
|
|
355
|
+
});
|
|
356
|
+
console.log(`Created: ${result.data.type.toUpperCase()}-${result.data.bugNumber} (${result.data.id})`);
|
|
357
|
+
} catch (e) {
|
|
358
|
+
console.error("Failed:", e.message);
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
process.exit(0);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
program
|
|
365
|
+
.command("templates")
|
|
366
|
+
.description("List templates and their review status")
|
|
367
|
+
.option("--status <status>", "Filter: draft, pending_review, approved, rejected")
|
|
368
|
+
.option("-e, --email <email>", "Login email")
|
|
369
|
+
.option("-p, --password <password>", "Login password")
|
|
370
|
+
.action(async (options) => {
|
|
371
|
+
await authenticate(options);
|
|
372
|
+
const creds = getCredentials();
|
|
373
|
+
if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
|
|
374
|
+
|
|
375
|
+
const listTemplates = httpsCallable(functions, "listTemplates");
|
|
376
|
+
try {
|
|
377
|
+
const result = await listTemplates({ projectId: creds.projectId, status: options.status || null });
|
|
378
|
+
const templates = result.data.templates;
|
|
379
|
+
if (templates.length === 0) {
|
|
380
|
+
console.log("No templates found.");
|
|
381
|
+
} else {
|
|
382
|
+
for (const t of templates) {
|
|
383
|
+
const statusIcon = { draft: "📝", pending_review: "🔍", approved: "✅", rejected: "❌" }[t.status] || "?";
|
|
384
|
+
console.log(` ${statusIcon} ${t.name} v${t.version || "-"} [${t.status}] (${t.sectionsCount} sections) ID: ${t.id}`);
|
|
385
|
+
if (t.reviewComment) console.log(` 💬 ${t.reviewComment}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} catch (e) {
|
|
389
|
+
console.error("Failed:", e.message);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
process.exit(0);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
program
|
|
396
|
+
.command("requirement-keys")
|
|
397
|
+
.description("List all requirement keys in the project (from backlog + templates)")
|
|
398
|
+
.option("-e, --email <email>", "Login email")
|
|
399
|
+
.option("-p, --password <password>", "Login password")
|
|
400
|
+
.action(async (options) => {
|
|
401
|
+
await authenticate(options);
|
|
402
|
+
const creds = getCredentials();
|
|
403
|
+
if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
|
|
404
|
+
|
|
405
|
+
const listKeys = httpsCallable(functions, "listRequirementKeys");
|
|
406
|
+
try {
|
|
407
|
+
const result = await listKeys({ projectId: creds.projectId });
|
|
408
|
+
const keys = result.data.keys;
|
|
409
|
+
if (keys.length === 0) {
|
|
410
|
+
console.log("No requirement keys found.");
|
|
411
|
+
} else {
|
|
412
|
+
console.log(`\n${keys.length} requirement keys:\n`);
|
|
413
|
+
for (const k of keys) {
|
|
414
|
+
const src = k.source === "backlog" ? `${k.type?.toUpperCase()}-${k.bugNumber} [${k.status}]` : `test ${k.testId}`;
|
|
415
|
+
console.log(` ${k.key}`);
|
|
416
|
+
console.log(` ${k.description}`);
|
|
417
|
+
console.log(` Source: ${src}`);
|
|
418
|
+
console.log();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} catch (e) {
|
|
422
|
+
console.error("Failed:", e.message);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
process.exit(0);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
program
|
|
429
|
+
.command("template-upload")
|
|
430
|
+
.description("Upload a test template JSON to the project")
|
|
431
|
+
.requiredOption("-f, --file <path>", "Path to template JSON file")
|
|
432
|
+
.option("-e, --email <email>", "Login email")
|
|
433
|
+
.option("-p, --password <password>", "Login password")
|
|
434
|
+
.action(async (options) => {
|
|
435
|
+
await authenticate(options);
|
|
436
|
+
const creds = getCredentials();
|
|
437
|
+
if (!creds?.projectId) { console.error("No project configured. Run: qatools setup"); process.exit(1); }
|
|
438
|
+
|
|
439
|
+
const filePath = path.resolve(options.file);
|
|
440
|
+
if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); process.exit(1); }
|
|
441
|
+
|
|
442
|
+
const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
443
|
+
if (!data.name || !data.sections) { console.error("JSON must have 'name' and 'sections'"); process.exit(1); }
|
|
444
|
+
|
|
445
|
+
const uploadTemplate = httpsCallable(functions, "uploadTemplate");
|
|
446
|
+
try {
|
|
447
|
+
const result = await uploadTemplate({
|
|
448
|
+
projectId: creds.projectId,
|
|
449
|
+
name: data.name,
|
|
450
|
+
version: data.version || "",
|
|
451
|
+
sections: data.sections,
|
|
452
|
+
});
|
|
453
|
+
console.log(`Template uploaded: ${result.data.name} (${result.data.sectionsCount} sections)`);
|
|
454
|
+
console.log(`ID: ${result.data.id}`);
|
|
455
|
+
} catch (e) {
|
|
456
|
+
console.error("Failed:", e.message);
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
process.exit(0);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
program
|
|
463
|
+
.command("backlog-update")
|
|
464
|
+
.description("Update one or more backlog items (JSON file or inline)")
|
|
465
|
+
.option("-f, --file <path>", "JSON file with updates: { '<id>': { status, comment, ... } }")
|
|
466
|
+
.option("-i, --id <id>", "Single item ID to update")
|
|
467
|
+
.option("--status <status>", "New status")
|
|
468
|
+
.option("--comment <text>", "Add comment")
|
|
469
|
+
.option("--assigned <email>", "Assign to")
|
|
470
|
+
.option("--tester <email>", "Set tester")
|
|
471
|
+
.option("--author <name>", "Author name for comments (default: your email)", "Claude")
|
|
472
|
+
.option("-e, --email <email>", "Login email")
|
|
473
|
+
.option("-p, --password <password>", "Login password")
|
|
474
|
+
.action(async (options) => {
|
|
475
|
+
await authenticate(options);
|
|
476
|
+
|
|
477
|
+
let updates = {};
|
|
478
|
+
if (options.file) {
|
|
479
|
+
updates = JSON.parse(fs.readFileSync(path.resolve(options.file), "utf-8"));
|
|
480
|
+
// Add authorName to all items with comments
|
|
481
|
+
for (const u of Object.values(updates)) {
|
|
482
|
+
if (u.comment && !u.authorName) u.authorName = options.author;
|
|
483
|
+
}
|
|
484
|
+
} else if (options.id) {
|
|
485
|
+
const u = {};
|
|
486
|
+
if (options.status) u.status = options.status;
|
|
487
|
+
if (options.comment) { u.comment = options.comment; u.authorName = options.author; }
|
|
488
|
+
if (options.assigned) u.assignedTo = options.assigned;
|
|
489
|
+
if (options.tester) u.tester = options.tester;
|
|
490
|
+
updates[options.id] = u;
|
|
491
|
+
} else {
|
|
492
|
+
console.error("Provide --file or --id");
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const updateItems = httpsCallable(functions, "updateBacklogItems");
|
|
497
|
+
try {
|
|
498
|
+
const result = await updateItems({ updates });
|
|
499
|
+
console.log(`Updated ${result.data.updatedCount} item(s)`);
|
|
500
|
+
} catch (e) {
|
|
501
|
+
console.error("Failed:", e.message);
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
process.exit(0);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
program
|
|
508
|
+
.command("login")
|
|
509
|
+
.description("Save credentials to ~/.qatools (email/password or --google)")
|
|
510
|
+
.option("-e, --email <email>", "Login email")
|
|
511
|
+
.option("-p, --password <password>", "Login password")
|
|
512
|
+
.option("-g, --google", "Login with Google (opens browser)")
|
|
513
|
+
.action(async (options) => {
|
|
514
|
+
const configPath = path.join(process.env.HOME || process.env.USERPROFILE, ".qatools");
|
|
515
|
+
|
|
516
|
+
if (options.google) {
|
|
517
|
+
// Google login via browser
|
|
518
|
+
console.log("Opening browser for Google sign-in...\n");
|
|
519
|
+
// Find a free port
|
|
520
|
+
const port = await new Promise((resolve, reject) => {
|
|
521
|
+
const srv = http.createServer();
|
|
522
|
+
srv.listen(0, () => { const p = srv.address().port; srv.close(() => resolve(p)); });
|
|
523
|
+
srv.on("error", reject);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const tokenPromise = new Promise((resolve, reject) => {
|
|
527
|
+
const server = http.createServer((req, res) => {
|
|
528
|
+
// CORS headers
|
|
529
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
530
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
531
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
532
|
+
|
|
533
|
+
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
|
534
|
+
|
|
535
|
+
if (req.method === "POST" && req.url === "/callback") {
|
|
536
|
+
let body = "";
|
|
537
|
+
req.on("data", chunk => body += chunk);
|
|
538
|
+
req.on("end", () => {
|
|
539
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
540
|
+
res.end(JSON.stringify({ ok: true }));
|
|
541
|
+
server.close();
|
|
542
|
+
try { resolve(JSON.parse(body)); } catch { reject(new Error("Invalid response")); }
|
|
543
|
+
});
|
|
544
|
+
} else {
|
|
545
|
+
res.writeHead(404); res.end();
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
server.listen(port, () => {
|
|
550
|
+
const url = `https://qa-tools.com/cli-auth?port=${port}`;
|
|
551
|
+
if (process.platform === "win32") {
|
|
552
|
+
require("child_process").exec(`rundll32 url.dll,FileProtocolHandler ${url}`);
|
|
553
|
+
} else if (process.platform === "darwin") {
|
|
554
|
+
require("child_process").exec(`open "${url}"`);
|
|
555
|
+
} else {
|
|
556
|
+
require("child_process").exec(`xdg-open "${url}"`);
|
|
557
|
+
}
|
|
558
|
+
console.log(`Waiting for sign-in at ${url}`);
|
|
559
|
+
console.log("(If browser didn't open, go to the URL manually)\n");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
setTimeout(() => { server.close(); reject(new Error("Timeout — no sign-in received after 2 minutes")); }, 120000);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
const { refreshToken, email } = await tokenPromise;
|
|
567
|
+
console.log(`Received token for ${email}. Verifying...`);
|
|
568
|
+
// Load existing config to preserve projectId
|
|
569
|
+
let existing = {};
|
|
570
|
+
if (fs.existsSync(configPath)) {
|
|
571
|
+
try { existing = JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch {}
|
|
572
|
+
}
|
|
573
|
+
const config = { ...existing, email, refreshToken };
|
|
574
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
575
|
+
console.log(`Logged in as ${email}`);
|
|
576
|
+
console.log(`Credentials saved to ${configPath}`);
|
|
577
|
+
} catch (e) {
|
|
578
|
+
console.error("Login failed:", e.message);
|
|
579
|
+
process.exit(1);
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
// Email/password login
|
|
583
|
+
if (!options.email || !options.password) {
|
|
584
|
+
console.error("Use --google or provide --email and --password");
|
|
585
|
+
process.exit(1);
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
await signInWithEmailAndPassword(auth, options.email, options.password);
|
|
589
|
+
} catch (e) {
|
|
590
|
+
console.error("Login failed:", e.message);
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
let existing = {};
|
|
594
|
+
if (fs.existsSync(configPath)) {
|
|
595
|
+
try { existing = JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch {}
|
|
596
|
+
}
|
|
597
|
+
const config = { ...existing, email: options.email, password: options.password };
|
|
598
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
599
|
+
console.log(`Credentials saved to ${configPath}`);
|
|
600
|
+
}
|
|
601
|
+
process.exit(0);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
program.parse();
|