@simonfestl/husky-cli 1.6.4 → 1.6.5
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/commands/biz/qdrant.js +29 -0
- package/dist/commands/completion.js +2 -2
- package/dist/commands/e2e.d.ts +11 -0
- package/dist/commands/e2e.js +528 -0
- package/dist/commands/infra.d.ts +11 -0
- package/dist/commands/infra.js +273 -0
- package/dist/commands/interactive/vm-sessions.js +2 -1
- package/dist/commands/pr.d.ts +11 -0
- package/dist/commands/pr.js +450 -0
- package/dist/commands/vm.js +2 -2
- package/dist/index.js +6 -0
- package/dist/lib/permissions.d.ts +17 -1
- package/dist/lib/permissions.js +27 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { QdrantClient } from "../../lib/biz/index.js";
|
|
8
|
+
import { requireRole } from "../../lib/permissions.js";
|
|
8
9
|
export const qdrantCommand = new Command("qdrant")
|
|
9
10
|
.description("Vector database operations (Qdrant)");
|
|
10
11
|
// husky biz qdrant collections
|
|
@@ -167,4 +168,32 @@ qdrantCommand
|
|
|
167
168
|
process.exit(1);
|
|
168
169
|
}
|
|
169
170
|
});
|
|
171
|
+
// husky biz qdrant delete-collection <name> (ADMIN ONLY)
|
|
172
|
+
qdrantCommand
|
|
173
|
+
.command("delete-collection <name>")
|
|
174
|
+
.description("Delete an entire collection (ADMIN ONLY)")
|
|
175
|
+
.option("-f, --force", "Skip confirmation")
|
|
176
|
+
.action(async (name, options) => {
|
|
177
|
+
try {
|
|
178
|
+
// Require admin role
|
|
179
|
+
requireRole("admin");
|
|
180
|
+
const client = QdrantClient.fromConfig();
|
|
181
|
+
// Get collection info first
|
|
182
|
+
const info = await client.getCollection(name);
|
|
183
|
+
if (!options.force) {
|
|
184
|
+
console.log(`\n ⚠️ DANGER: About to DELETE collection "${name}"`);
|
|
185
|
+
console.log(` Points: ${info.pointsCount}`);
|
|
186
|
+
console.log(` Vectors: ${info.vectorsCount}`);
|
|
187
|
+
console.log(`\n This action is IRREVERSIBLE!`);
|
|
188
|
+
console.log(` Use --force to confirm deletion\n`);
|
|
189
|
+
process.exit(0);
|
|
190
|
+
}
|
|
191
|
+
await client.deleteCollection(name);
|
|
192
|
+
console.log(`✓ Deleted collection "${name}"`);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
console.error("Error:", error.message);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
170
199
|
export default qdrantCommand;
|
|
@@ -135,7 +135,7 @@ ${subcommandCases}
|
|
|
135
135
|
return 0
|
|
136
136
|
;;
|
|
137
137
|
--agent)
|
|
138
|
-
COMPREPLY=( $(compgen -W "claude-code gemini-cli custom" -- \${cur}) )
|
|
138
|
+
COMPREPLY=( $(compgen -W "claude-code gemini-cli opencode custom" -- \${cur}) )
|
|
139
139
|
return 0
|
|
140
140
|
;;
|
|
141
141
|
--type)
|
|
@@ -268,7 +268,7 @@ complete -c husky -l project -d "Project ID" -r
|
|
|
268
268
|
complete -c husky -l status -d "Filter by status" -r -a "backlog in_progress review done pending running completed failed"
|
|
269
269
|
complete -c husky -l priority -d "Priority level" -r -a "low medium high urgent must should could wont"
|
|
270
270
|
complete -c husky -l assignee -d "Assignee type" -r -a "human llm unassigned"
|
|
271
|
-
complete -c husky -l agent -d "Agent type" -r -a "claude-code gemini-cli custom"
|
|
271
|
+
complete -c husky -l agent -d "Agent type" -r -a "claude-code gemini-cli opencode custom"
|
|
272
272
|
complete -c husky -l type -d "Type filter" -r -a "global project architecture patterns decisions learnings"
|
|
273
273
|
complete -c husky -l value-stream -d "Value stream" -r -a "order_to_delivery procure_to_pay returns_management product_lifecycle customer_service marketing_sales finance_accounting hr_operations it_operations general"
|
|
274
274
|
complete -c husky -l action -d "Action type" -r -a "manual semi_automated fully_automated"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Testing commands for E2E Agent
|
|
3
|
+
*
|
|
4
|
+
* Commands for browser automation and E2E testing:
|
|
5
|
+
* - husky e2e run <task-id> - Run E2E tests for a task
|
|
6
|
+
* - husky e2e record <url> - Record a browser session with Playwright
|
|
7
|
+
* - husky e2e upload <file> - Upload recording to GCS bucket husky-files-tigerv0
|
|
8
|
+
* - husky e2e screenshot <url> - Take a screenshot of a URL
|
|
9
|
+
*/
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
export declare const e2eCommand: Command;
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Testing commands for E2E Agent
|
|
3
|
+
*
|
|
4
|
+
* Commands for browser automation and E2E testing:
|
|
5
|
+
* - husky e2e run <task-id> - Run E2E tests for a task
|
|
6
|
+
* - husky e2e record <url> - Record a browser session with Playwright
|
|
7
|
+
* - husky e2e upload <file> - Upload recording to GCS bucket husky-files-tigerv0
|
|
8
|
+
* - husky e2e screenshot <url> - Take a screenshot of a URL
|
|
9
|
+
*/
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import { spawnSync } from "child_process";
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { requireAnyPermission } from "../lib/permissions.js";
|
|
15
|
+
import { getConfig } from "./config.js";
|
|
16
|
+
export const e2eCommand = new Command("e2e")
|
|
17
|
+
.description("E2E testing and browser automation (E2E Agent)");
|
|
18
|
+
// GCS bucket for E2E artifacts
|
|
19
|
+
const GCS_BUCKET = "husky-files-tigerv0";
|
|
20
|
+
// Helper: Ensure API is configured
|
|
21
|
+
function ensureConfig() {
|
|
22
|
+
const config = getConfig();
|
|
23
|
+
if (!config.apiUrl) {
|
|
24
|
+
console.error("Error: API URL not configured. Run: husky config set api-url <url>");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
return config;
|
|
28
|
+
}
|
|
29
|
+
// Helper: Run a command and return result
|
|
30
|
+
function runCommand(cmd, args, options) {
|
|
31
|
+
const result = spawnSync(cmd, args, {
|
|
32
|
+
encoding: "utf-8",
|
|
33
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
34
|
+
timeout: options?.timeout || 300000, // 5 min default
|
|
35
|
+
cwd: options?.cwd,
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
stdout: result.stdout || "",
|
|
39
|
+
stderr: result.stderr || "",
|
|
40
|
+
success: result.status === 0,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// Helper: Generate timestamp for filenames
|
|
44
|
+
function generateTimestamp() {
|
|
45
|
+
const now = new Date();
|
|
46
|
+
return now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
47
|
+
}
|
|
48
|
+
// Helper: Upload file to GCS
|
|
49
|
+
function uploadToGCS(localPath, gcsPath) {
|
|
50
|
+
const fullGcsPath = `gs://${GCS_BUCKET}/${gcsPath}`;
|
|
51
|
+
const result = runCommand("gcloud", [
|
|
52
|
+
"storage", "cp", localPath, fullGcsPath,
|
|
53
|
+
]);
|
|
54
|
+
if (result.success) {
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
url: `https://storage.googleapis.com/${GCS_BUCKET}/${gcsPath}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
console.error("GCS upload failed:", result.stderr);
|
|
61
|
+
return { success: false, url: "" };
|
|
62
|
+
}
|
|
63
|
+
// husky e2e run <task-id>
|
|
64
|
+
e2eCommand
|
|
65
|
+
.command("run <taskId>")
|
|
66
|
+
.description("Run E2E tests for a task")
|
|
67
|
+
.option("--headless", "Run in headless mode (default: true)", true)
|
|
68
|
+
.option("--headed", "Run in headed mode with visible browser")
|
|
69
|
+
.option("--browser <browser>", "Browser to use (chromium, firefox, webkit)", "chromium")
|
|
70
|
+
.option("--timeout <ms>", "Test timeout in milliseconds", "60000")
|
|
71
|
+
.option("--output <dir>", "Output directory for results")
|
|
72
|
+
.option("--json", "Output as JSON")
|
|
73
|
+
.action(async (taskId, options) => {
|
|
74
|
+
requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
|
|
75
|
+
const config = ensureConfig();
|
|
76
|
+
const timestamp = generateTimestamp();
|
|
77
|
+
const outputDir = options.output || `/tmp/e2e-${taskId}-${timestamp}`;
|
|
78
|
+
console.log(`\n E2E Tests for Task: ${taskId}\n`);
|
|
79
|
+
console.log(` Output directory: ${outputDir}`);
|
|
80
|
+
console.log(` Browser: ${options.browser}`);
|
|
81
|
+
console.log(` Headless: ${!options.headed}`);
|
|
82
|
+
console.log("");
|
|
83
|
+
// Create output directory
|
|
84
|
+
if (!fs.existsSync(outputDir)) {
|
|
85
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
// Fetch task details from API
|
|
88
|
+
let taskUrl;
|
|
89
|
+
let taskName;
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(`${config.apiUrl}/api/tasks/${taskId}`, {
|
|
92
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
93
|
+
});
|
|
94
|
+
if (res.ok) {
|
|
95
|
+
const task = await res.json();
|
|
96
|
+
taskName = task.title || task.name;
|
|
97
|
+
// Check if task has a preview URL or target URL
|
|
98
|
+
taskUrl = task.previewUrl || task.targetUrl || task.metadata?.url;
|
|
99
|
+
console.log(` Task: ${taskName}`);
|
|
100
|
+
if (taskUrl) {
|
|
101
|
+
console.log(` URL: ${taskUrl}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
console.warn(" Warning: Could not fetch task details");
|
|
107
|
+
}
|
|
108
|
+
// Look for test files in current directory or task worktree
|
|
109
|
+
const testPatterns = [
|
|
110
|
+
"e2e/**/*.spec.ts",
|
|
111
|
+
"e2e/**/*.test.ts",
|
|
112
|
+
"tests/e2e/**/*.spec.ts",
|
|
113
|
+
"tests/e2e/**/*.test.ts",
|
|
114
|
+
"*.e2e.ts",
|
|
115
|
+
];
|
|
116
|
+
console.log("\n Running Playwright tests...\n");
|
|
117
|
+
const playwrightArgs = [
|
|
118
|
+
"test",
|
|
119
|
+
"--reporter=list",
|
|
120
|
+
`--output=${outputDir}`,
|
|
121
|
+
`--timeout=${options.timeout}`,
|
|
122
|
+
];
|
|
123
|
+
if (!options.headed) {
|
|
124
|
+
playwrightArgs.push("--headed=false");
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
playwrightArgs.push("--headed");
|
|
128
|
+
}
|
|
129
|
+
if (options.browser) {
|
|
130
|
+
playwrightArgs.push(`--project=${options.browser}`);
|
|
131
|
+
}
|
|
132
|
+
const result = runCommand("npx", ["playwright", ...playwrightArgs], {
|
|
133
|
+
timeout: parseInt(options.timeout, 10) * 2, // Allow 2x timeout for test suite
|
|
134
|
+
});
|
|
135
|
+
const testResult = {
|
|
136
|
+
taskId,
|
|
137
|
+
taskName,
|
|
138
|
+
taskUrl,
|
|
139
|
+
success: result.success,
|
|
140
|
+
timestamp,
|
|
141
|
+
outputDir,
|
|
142
|
+
browser: options.browser,
|
|
143
|
+
stdout: result.stdout,
|
|
144
|
+
stderr: result.stderr,
|
|
145
|
+
};
|
|
146
|
+
// Save results to file
|
|
147
|
+
const resultsPath = path.join(outputDir, "results.json");
|
|
148
|
+
fs.writeFileSync(resultsPath, JSON.stringify(testResult, null, 2));
|
|
149
|
+
// Upload results to GCS
|
|
150
|
+
const gcsPath = `e2e/${taskId}/${timestamp}/results.json`;
|
|
151
|
+
const uploadResult = uploadToGCS(resultsPath, gcsPath);
|
|
152
|
+
if (options.json) {
|
|
153
|
+
console.log(JSON.stringify({
|
|
154
|
+
...testResult,
|
|
155
|
+
gcsUrl: uploadResult.url,
|
|
156
|
+
}, null, 2));
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
console.log(result.stdout);
|
|
160
|
+
if (result.stderr) {
|
|
161
|
+
console.error(result.stderr);
|
|
162
|
+
}
|
|
163
|
+
console.log("\n " + "═".repeat(50));
|
|
164
|
+
console.log(` Result: ${result.success ? "PASSED" : "FAILED"}`);
|
|
165
|
+
console.log(` Output: ${outputDir}`);
|
|
166
|
+
if (uploadResult.success) {
|
|
167
|
+
console.log(` GCS: ${uploadResult.url}`);
|
|
168
|
+
}
|
|
169
|
+
console.log("");
|
|
170
|
+
}
|
|
171
|
+
if (!result.success) {
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
// husky e2e record <url>
|
|
176
|
+
e2eCommand
|
|
177
|
+
.command("record <url>")
|
|
178
|
+
.description("Record a browser session with Playwright codegen")
|
|
179
|
+
.option("-o, --output <file>", "Output file for recorded script")
|
|
180
|
+
.option("--browser <browser>", "Browser to use (chromium, firefox, webkit)", "chromium")
|
|
181
|
+
.option("--device <device>", "Emulate device (e.g., 'iPhone 13', 'Pixel 5')")
|
|
182
|
+
.option("--viewport <size>", "Viewport size (e.g., '1280x720')")
|
|
183
|
+
.option("--video", "Record video of the session")
|
|
184
|
+
.option("--json", "Output as JSON")
|
|
185
|
+
.action(async (url, options) => {
|
|
186
|
+
requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
|
|
187
|
+
const timestamp = generateTimestamp();
|
|
188
|
+
const outputFile = options.output || `/tmp/e2e-recording-${timestamp}.ts`;
|
|
189
|
+
const videoDir = `/tmp/e2e-video-${timestamp}`;
|
|
190
|
+
console.log(`\n Recording Browser Session\n`);
|
|
191
|
+
console.log(` URL: ${url}`);
|
|
192
|
+
console.log(` Browser: ${options.browser}`);
|
|
193
|
+
console.log(` Output: ${outputFile}`);
|
|
194
|
+
if (options.device) {
|
|
195
|
+
console.log(` Device: ${options.device}`);
|
|
196
|
+
}
|
|
197
|
+
if (options.viewport) {
|
|
198
|
+
console.log(` Viewport: ${options.viewport}`);
|
|
199
|
+
}
|
|
200
|
+
console.log("");
|
|
201
|
+
const codegenArgs = [
|
|
202
|
+
"playwright", "codegen",
|
|
203
|
+
url,
|
|
204
|
+
`-o`, outputFile,
|
|
205
|
+
`-b`, options.browser,
|
|
206
|
+
];
|
|
207
|
+
if (options.device) {
|
|
208
|
+
codegenArgs.push("--device", options.device);
|
|
209
|
+
}
|
|
210
|
+
if (options.viewport) {
|
|
211
|
+
const [width, height] = options.viewport.split("x").map(Number);
|
|
212
|
+
codegenArgs.push("--viewport-size", `${width},${height}`);
|
|
213
|
+
}
|
|
214
|
+
if (options.video) {
|
|
215
|
+
if (!fs.existsSync(videoDir)) {
|
|
216
|
+
fs.mkdirSync(videoDir, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
codegenArgs.push("--save-storage", path.join(videoDir, "storage.json"));
|
|
219
|
+
}
|
|
220
|
+
console.log(" Starting Playwright Codegen...");
|
|
221
|
+
console.log(" Interact with the browser, then close it to save the recording.\n");
|
|
222
|
+
// Run codegen interactively
|
|
223
|
+
const codegen = spawnSync("npx", codegenArgs, {
|
|
224
|
+
stdio: "inherit",
|
|
225
|
+
encoding: "utf-8",
|
|
226
|
+
});
|
|
227
|
+
if (codegen.status === 0 && fs.existsSync(outputFile)) {
|
|
228
|
+
console.log(`\n Recording saved to: ${outputFile}`);
|
|
229
|
+
// Upload to GCS
|
|
230
|
+
const gcsPath = `e2e/recordings/${timestamp}/${path.basename(outputFile)}`;
|
|
231
|
+
const uploadResult = uploadToGCS(outputFile, gcsPath);
|
|
232
|
+
if (options.json) {
|
|
233
|
+
console.log(JSON.stringify({
|
|
234
|
+
success: true,
|
|
235
|
+
url,
|
|
236
|
+
outputFile,
|
|
237
|
+
gcsUrl: uploadResult.url,
|
|
238
|
+
timestamp,
|
|
239
|
+
}, null, 2));
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
if (uploadResult.success) {
|
|
243
|
+
console.log(` Uploaded to: ${uploadResult.url}`);
|
|
244
|
+
}
|
|
245
|
+
console.log("\n To run this recording:");
|
|
246
|
+
console.log(` npx playwright test ${outputFile}`);
|
|
247
|
+
console.log("");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
console.error("\n Recording failed or was cancelled.");
|
|
252
|
+
process.exit(1);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
// husky e2e upload <file>
|
|
256
|
+
e2eCommand
|
|
257
|
+
.command("upload <file>")
|
|
258
|
+
.description("Upload a file to the E2E GCS bucket")
|
|
259
|
+
.option("--task <taskId>", "Associate with a task ID")
|
|
260
|
+
.option("--type <type>", "File type (screenshot, video, recording, report)", "file")
|
|
261
|
+
.option("--json", "Output as JSON")
|
|
262
|
+
.action(async (file, options) => {
|
|
263
|
+
requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
|
|
264
|
+
if (!fs.existsSync(file)) {
|
|
265
|
+
console.error(`Error: File not found: ${file}`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
const timestamp = generateTimestamp();
|
|
269
|
+
const filename = path.basename(file);
|
|
270
|
+
const taskPrefix = options.task ? `${options.task}/` : "";
|
|
271
|
+
const gcsPath = `e2e/${taskPrefix}${options.type}s/${timestamp}-${filename}`;
|
|
272
|
+
console.log(`\n Uploading to GCS\n`);
|
|
273
|
+
console.log(` File: ${file}`);
|
|
274
|
+
console.log(` Type: ${options.type}`);
|
|
275
|
+
if (options.task) {
|
|
276
|
+
console.log(` Task: ${options.task}`);
|
|
277
|
+
}
|
|
278
|
+
console.log("");
|
|
279
|
+
const uploadResult = uploadToGCS(file, gcsPath);
|
|
280
|
+
if (options.json) {
|
|
281
|
+
console.log(JSON.stringify({
|
|
282
|
+
success: uploadResult.success,
|
|
283
|
+
file,
|
|
284
|
+
gcsPath,
|
|
285
|
+
url: uploadResult.url,
|
|
286
|
+
timestamp,
|
|
287
|
+
}, null, 2));
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
if (uploadResult.success) {
|
|
291
|
+
console.log(` Uploaded successfully!`);
|
|
292
|
+
console.log(` URL: ${uploadResult.url}`);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
console.error(` Upload failed.`);
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
console.log("");
|
|
300
|
+
});
|
|
301
|
+
// husky e2e screenshot <url>
|
|
302
|
+
e2eCommand
|
|
303
|
+
.command("screenshot <url>")
|
|
304
|
+
.description("Take a screenshot of a URL")
|
|
305
|
+
.option("-o, --output <file>", "Output file path")
|
|
306
|
+
.option("--full-page", "Capture full page (not just viewport)")
|
|
307
|
+
.option("--viewport <size>", "Viewport size (e.g., '1920x1080')", "1920x1080")
|
|
308
|
+
.option("--device <device>", "Emulate device (e.g., 'iPhone 13', 'Pixel 5')")
|
|
309
|
+
.option("--wait <ms>", "Wait time before screenshot in ms", "2000")
|
|
310
|
+
.option("--selector <selector>", "Wait for selector before screenshot")
|
|
311
|
+
.option("--task <taskId>", "Associate with a task ID")
|
|
312
|
+
.option("--upload", "Upload screenshot to GCS")
|
|
313
|
+
.option("--json", "Output as JSON")
|
|
314
|
+
.action(async (url, options) => {
|
|
315
|
+
requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
|
|
316
|
+
const timestamp = generateTimestamp();
|
|
317
|
+
const defaultFilename = `screenshot-${timestamp}.png`;
|
|
318
|
+
const outputFile = options.output || `/tmp/${defaultFilename}`;
|
|
319
|
+
console.log(`\n Taking Screenshot\n`);
|
|
320
|
+
console.log(` URL: ${url}`);
|
|
321
|
+
console.log(` Viewport: ${options.viewport}`);
|
|
322
|
+
console.log(` Full page: ${options.fullPage || false}`);
|
|
323
|
+
if (options.device) {
|
|
324
|
+
console.log(` Device: ${options.device}`);
|
|
325
|
+
}
|
|
326
|
+
console.log(` Output: ${outputFile}`);
|
|
327
|
+
console.log("");
|
|
328
|
+
// Build Playwright script
|
|
329
|
+
const [width, height] = options.viewport.split("x").map(Number);
|
|
330
|
+
const deviceConfig = options.device ? `devices[${JSON.stringify(options.device)}]` : "null";
|
|
331
|
+
// Use JSON.stringify to safely encode user-provided values and prevent injection
|
|
332
|
+
const safeUrl = JSON.stringify(url);
|
|
333
|
+
const safeOutputFile = JSON.stringify(outputFile);
|
|
334
|
+
const safeSelector = options.selector ? JSON.stringify(options.selector) : null;
|
|
335
|
+
const script = `
|
|
336
|
+
const { chromium, devices } = require('playwright');
|
|
337
|
+
|
|
338
|
+
(async () => {
|
|
339
|
+
const browser = await chromium.launch({ headless: true });
|
|
340
|
+
const deviceConfig = ${deviceConfig};
|
|
341
|
+
const contextOptions = deviceConfig ? { ...deviceConfig } : {
|
|
342
|
+
viewport: { width: ${width}, height: ${height} }
|
|
343
|
+
};
|
|
344
|
+
const context = await browser.newContext(contextOptions);
|
|
345
|
+
const page = await context.newPage();
|
|
346
|
+
|
|
347
|
+
await page.goto(${safeUrl}, { waitUntil: 'networkidle' });
|
|
348
|
+
|
|
349
|
+
${safeSelector ? `await page.waitForSelector(${safeSelector});` : ""}
|
|
350
|
+
await page.waitForTimeout(${options.wait});
|
|
351
|
+
|
|
352
|
+
await page.screenshot({
|
|
353
|
+
path: ${safeOutputFile},
|
|
354
|
+
fullPage: ${options.fullPage || false}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await browser.close();
|
|
358
|
+
console.log('Screenshot saved to: ' + ${safeOutputFile});
|
|
359
|
+
})();
|
|
360
|
+
`;
|
|
361
|
+
// Write and execute script
|
|
362
|
+
const scriptPath = `/tmp/screenshot-script-${timestamp}.js`;
|
|
363
|
+
fs.writeFileSync(scriptPath, script);
|
|
364
|
+
console.log(" Capturing screenshot...\n");
|
|
365
|
+
const result = runCommand("node", [scriptPath], { timeout: 60000 });
|
|
366
|
+
// Clean up script
|
|
367
|
+
try {
|
|
368
|
+
fs.unlinkSync(scriptPath);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Ignore cleanup errors - temp script file may already be deleted
|
|
372
|
+
}
|
|
373
|
+
if (!result.success || !fs.existsSync(outputFile)) {
|
|
374
|
+
console.error(" Screenshot failed:");
|
|
375
|
+
console.error(result.stderr || result.stdout);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
const fileStats = fs.statSync(outputFile);
|
|
379
|
+
let gcsUrl = "";
|
|
380
|
+
// Upload if requested
|
|
381
|
+
if (options.upload) {
|
|
382
|
+
const taskPrefix = options.task ? `${options.task}/` : "";
|
|
383
|
+
const gcsPath = `e2e/${taskPrefix}screenshots/${defaultFilename}`;
|
|
384
|
+
const uploadResult = uploadToGCS(outputFile, gcsPath);
|
|
385
|
+
gcsUrl = uploadResult.url;
|
|
386
|
+
}
|
|
387
|
+
if (options.json) {
|
|
388
|
+
console.log(JSON.stringify({
|
|
389
|
+
success: true,
|
|
390
|
+
url,
|
|
391
|
+
outputFile,
|
|
392
|
+
fileSize: fileStats.size,
|
|
393
|
+
viewport: options.viewport,
|
|
394
|
+
fullPage: options.fullPage || false,
|
|
395
|
+
gcsUrl: gcsUrl || undefined,
|
|
396
|
+
timestamp,
|
|
397
|
+
}, null, 2));
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
console.log(` Screenshot captured successfully!`);
|
|
401
|
+
console.log(` File: ${outputFile}`);
|
|
402
|
+
console.log(` Size: ${(fileStats.size / 1024).toFixed(1)} KB`);
|
|
403
|
+
if (gcsUrl) {
|
|
404
|
+
console.log(` GCS: ${gcsUrl}`);
|
|
405
|
+
}
|
|
406
|
+
console.log("");
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
// husky e2e list
|
|
410
|
+
e2eCommand
|
|
411
|
+
.command("list")
|
|
412
|
+
.description("List E2E artifacts in GCS bucket")
|
|
413
|
+
.option("--task <taskId>", "Filter by task ID")
|
|
414
|
+
.option("--type <type>", "Filter by type (screenshots, videos, recordings, reports)")
|
|
415
|
+
.option("--limit <n>", "Limit number of results", "20")
|
|
416
|
+
.option("--json", "Output as JSON")
|
|
417
|
+
.action(async (options) => {
|
|
418
|
+
requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
|
|
419
|
+
let gcsPath = `gs://${GCS_BUCKET}/e2e/`;
|
|
420
|
+
if (options.task) {
|
|
421
|
+
gcsPath += `${options.task}/`;
|
|
422
|
+
}
|
|
423
|
+
if (options.type) {
|
|
424
|
+
gcsPath += `${options.type}/`;
|
|
425
|
+
}
|
|
426
|
+
console.log(`\n E2E Artifacts\n`);
|
|
427
|
+
console.log(` Bucket: ${GCS_BUCKET}`);
|
|
428
|
+
if (options.task) {
|
|
429
|
+
console.log(` Task: ${options.task}`);
|
|
430
|
+
}
|
|
431
|
+
if (options.type) {
|
|
432
|
+
console.log(` Type: ${options.type}`);
|
|
433
|
+
}
|
|
434
|
+
console.log("");
|
|
435
|
+
const result = runCommand("gcloud", [
|
|
436
|
+
"storage", "ls", "-l", gcsPath,
|
|
437
|
+
]);
|
|
438
|
+
if (!result.success) {
|
|
439
|
+
if (result.stderr.includes("CommandException") || result.stderr.includes("not found")) {
|
|
440
|
+
console.log(" No artifacts found.");
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
console.error("Error listing artifacts:", result.stderr);
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
|
448
|
+
const artifacts = lines
|
|
449
|
+
.slice(0, parseInt(options.limit, 10))
|
|
450
|
+
.map((line) => {
|
|
451
|
+
const parts = line.trim().split(/\s+/);
|
|
452
|
+
if (parts.length >= 3) {
|
|
453
|
+
return {
|
|
454
|
+
size: parts[0],
|
|
455
|
+
date: parts[1],
|
|
456
|
+
path: parts[2],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
})
|
|
461
|
+
.filter(Boolean);
|
|
462
|
+
if (options.json) {
|
|
463
|
+
console.log(JSON.stringify({ artifacts }, null, 2));
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
console.log(" " + "-".repeat(70));
|
|
467
|
+
console.log(` ${"SIZE".padEnd(12)} ${"DATE".padEnd(20)} PATH`);
|
|
468
|
+
console.log(" " + "-".repeat(70));
|
|
469
|
+
for (const artifact of artifacts) {
|
|
470
|
+
if (artifact) {
|
|
471
|
+
const displayPath = artifact.path.replace(`gs://${GCS_BUCKET}/`, "");
|
|
472
|
+
console.log(` ${artifact.size.padEnd(12)} ${artifact.date.padEnd(20)} ${displayPath}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
console.log("");
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
// husky e2e clean
|
|
479
|
+
e2eCommand
|
|
480
|
+
.command("clean")
|
|
481
|
+
.description("Clean up local E2E artifacts")
|
|
482
|
+
.option("--all", "Remove all E2E artifacts from /tmp")
|
|
483
|
+
.option("--older-than <days>", "Remove artifacts older than N days", "7")
|
|
484
|
+
.option("--dry-run", "Show what would be deleted without deleting")
|
|
485
|
+
.action(async (options) => {
|
|
486
|
+
requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
|
|
487
|
+
console.log(`\n Cleaning E2E Artifacts\n`);
|
|
488
|
+
const tmpDir = "/tmp";
|
|
489
|
+
const e2ePatterns = ["e2e-", "screenshot-", "recording-"];
|
|
490
|
+
const maxAge = parseInt(options.olderThan, 10) * 24 * 60 * 60 * 1000;
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
let cleaned = 0;
|
|
493
|
+
let totalSize = 0;
|
|
494
|
+
try {
|
|
495
|
+
const files = fs.readdirSync(tmpDir);
|
|
496
|
+
for (const file of files) {
|
|
497
|
+
const isE2E = e2ePatterns.some((p) => file.startsWith(p));
|
|
498
|
+
if (!isE2E)
|
|
499
|
+
continue;
|
|
500
|
+
const filePath = path.join(tmpDir, file);
|
|
501
|
+
const stats = fs.statSync(filePath);
|
|
502
|
+
const age = now - stats.mtime.getTime();
|
|
503
|
+
if (options.all || age > maxAge) {
|
|
504
|
+
if (options.dryRun) {
|
|
505
|
+
console.log(` Would delete: ${file} (${(stats.size / 1024).toFixed(1)} KB)`);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
if (stats.isDirectory()) {
|
|
509
|
+
fs.rmSync(filePath, { recursive: true });
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
fs.unlinkSync(filePath);
|
|
513
|
+
}
|
|
514
|
+
console.log(` Deleted: ${file}`);
|
|
515
|
+
}
|
|
516
|
+
cleaned++;
|
|
517
|
+
totalSize += stats.size;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
console.error("Error cleaning artifacts:", error);
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
console.log("");
|
|
526
|
+
console.log(` ${options.dryRun ? "Would clean" : "Cleaned"}: ${cleaned} items (${(totalSize / 1024 / 1024).toFixed(2)} MB)`);
|
|
527
|
+
console.log("");
|
|
528
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure commands for DevOps agent
|
|
3
|
+
*
|
|
4
|
+
* Commands for monitoring and managing GCP infrastructure:
|
|
5
|
+
* - husky infra status - Show all services status
|
|
6
|
+
* - husky infra vms - List VMs with status
|
|
7
|
+
* - husky infra logs <service> - View recent logs
|
|
8
|
+
* - husky infra scale <pool> --count <n> - Scale VM pool
|
|
9
|
+
*/
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
export declare const infraCommand: Command;
|