@allenpan2026/harshjudge 0.4.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/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +11 -0
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/dist/cli.js +1869 -0
- package/dist/cli.js.map +1 -0
- package/dist/dashboard-worker.js +896 -0
- package/dist/dashboard-worker.js.map +1 -0
- package/package.json +64 -0
- package/skills/harshjudge/SKILL.md +152 -0
- package/skills/harshjudge/assets/prd.md +36 -0
- package/skills/harshjudge/references/create.md +258 -0
- package/skills/harshjudge/references/iterate.md +152 -0
- package/skills/harshjudge/references/run-playwright.md +41 -0
- package/skills/harshjudge/references/run-step-agent.md +65 -0
- package/skills/harshjudge/references/run.md +129 -0
- package/skills/harshjudge/references/setup.md +129 -0
- package/skills/harshjudge/references/status.md +134 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1869 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command as Command11 } from "commander";
|
|
5
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
6
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
7
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
8
|
+
|
|
9
|
+
// src/commands/init.ts
|
|
10
|
+
import "commander";
|
|
11
|
+
|
|
12
|
+
// src/types/scenario.ts
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var StepReferenceSchema = z.object({
|
|
15
|
+
id: z.string().regex(/^\d{2}$/, "Step ID must be zero-padded (01, 02, etc.)"),
|
|
16
|
+
title: z.string().min(1),
|
|
17
|
+
file: z.string().regex(/^\d{2}-[\w-]+\.md$/, "Step file must match pattern: {id}-{slug}.md")
|
|
18
|
+
});
|
|
19
|
+
var ScenarioMetaSchema = z.object({
|
|
20
|
+
// Scenario definition
|
|
21
|
+
slug: z.string().regex(/^[a-z0-9-]+$/),
|
|
22
|
+
title: z.string().min(1),
|
|
23
|
+
starred: z.boolean().default(false),
|
|
24
|
+
tags: z.array(z.string()).default([]),
|
|
25
|
+
estimatedDuration: z.number().positive().default(60),
|
|
26
|
+
steps: z.array(StepReferenceSchema).default([]),
|
|
27
|
+
// Statistics (machine-updated)
|
|
28
|
+
totalRuns: z.number().nonnegative().default(0),
|
|
29
|
+
passCount: z.number().nonnegative().default(0),
|
|
30
|
+
failCount: z.number().nonnegative().default(0),
|
|
31
|
+
lastRun: z.string().nullable().default(null),
|
|
32
|
+
lastResult: z.enum(["pass", "fail"]).nullable().default(null),
|
|
33
|
+
avgDuration: z.number().nonnegative().default(0)
|
|
34
|
+
});
|
|
35
|
+
var DEFAULT_SCENARIO_STATS = {
|
|
36
|
+
totalRuns: 0,
|
|
37
|
+
passCount: 0,
|
|
38
|
+
failCount: 0,
|
|
39
|
+
lastRun: null,
|
|
40
|
+
lastResult: null,
|
|
41
|
+
avgDuration: 0
|
|
42
|
+
};
|
|
43
|
+
function padStepId(n) {
|
|
44
|
+
return String(n).padStart(2, "0");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/types/run.ts
|
|
48
|
+
import { z as z2 } from "zod";
|
|
49
|
+
var StepResultSchema = z2.object({
|
|
50
|
+
id: z2.string().regex(/^\d{2}$/, "Step ID must be zero-padded"),
|
|
51
|
+
status: z2.enum(["pass", "fail", "skipped"]),
|
|
52
|
+
duration: z2.number().nonnegative().optional().default(0),
|
|
53
|
+
error: z2.string().nullable().default(null),
|
|
54
|
+
evidenceFiles: z2.array(z2.string()).default([]),
|
|
55
|
+
/** AI-generated summary describing what happened in this step */
|
|
56
|
+
summary: z2.string().nullable().optional().default(null)
|
|
57
|
+
});
|
|
58
|
+
var RunResultSchema = z2.object({
|
|
59
|
+
runId: z2.string(),
|
|
60
|
+
scenarioSlug: z2.string().optional(),
|
|
61
|
+
// Optional for backward compat
|
|
62
|
+
status: z2.enum(["pass", "fail", "running"]),
|
|
63
|
+
startedAt: z2.string(),
|
|
64
|
+
completedAt: z2.string().optional(),
|
|
65
|
+
duration: z2.number().nonnegative().optional().default(0),
|
|
66
|
+
steps: z2.array(StepResultSchema).default([]),
|
|
67
|
+
failedStep: z2.string().nullable().default(null),
|
|
68
|
+
// Changed from number to string (step ID)
|
|
69
|
+
errorMessage: z2.string().nullable().default(null)
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// src/schemas/index.ts
|
|
73
|
+
import { z as z3 } from "zod";
|
|
74
|
+
var InitProjectParamsSchema = z3.object({
|
|
75
|
+
projectName: z3.string().min(1).max(100),
|
|
76
|
+
baseUrl: z3.string().url().optional()
|
|
77
|
+
});
|
|
78
|
+
var SaveScenarioParamsSchema = z3.object({
|
|
79
|
+
slug: z3.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
|
|
80
|
+
title: z3.string().min(1).max(200),
|
|
81
|
+
content: z3.string().min(1),
|
|
82
|
+
tags: z3.array(z3.string()).optional().default([]),
|
|
83
|
+
estimatedDuration: z3.number().positive().optional().default(60)
|
|
84
|
+
});
|
|
85
|
+
var StepInputSchema = z3.object({
|
|
86
|
+
title: z3.string().min(1),
|
|
87
|
+
description: z3.string().optional().default(""),
|
|
88
|
+
preconditions: z3.string().optional().default(""),
|
|
89
|
+
actions: z3.string().min(1),
|
|
90
|
+
expectedOutcome: z3.string().min(1)
|
|
91
|
+
});
|
|
92
|
+
var CreateScenarioParamsSchema = z3.object({
|
|
93
|
+
slug: z3.string().regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
|
|
94
|
+
title: z3.string().min(1).max(200),
|
|
95
|
+
steps: z3.array(StepInputSchema).min(1),
|
|
96
|
+
tags: z3.array(z3.string()).optional().default([]),
|
|
97
|
+
estimatedDuration: z3.number().positive().optional().default(60),
|
|
98
|
+
starred: z3.boolean().optional().default(false)
|
|
99
|
+
});
|
|
100
|
+
var ToggleStarParamsSchema = z3.object({
|
|
101
|
+
scenarioSlug: z3.string().regex(/^[a-z0-9-]+$/),
|
|
102
|
+
starred: z3.boolean().optional()
|
|
103
|
+
// If omitted, toggles current state
|
|
104
|
+
});
|
|
105
|
+
var StartRunParamsSchema = z3.object({
|
|
106
|
+
scenarioSlug: z3.string().regex(/^[a-z0-9-]+$/)
|
|
107
|
+
});
|
|
108
|
+
var RecordEvidenceParamsSchema = z3.object({
|
|
109
|
+
runId: z3.string().min(1),
|
|
110
|
+
step: z3.number().int().positive(),
|
|
111
|
+
// v2: accepts number, will be converted to zero-padded string
|
|
112
|
+
type: z3.enum([
|
|
113
|
+
"screenshot",
|
|
114
|
+
"db_snapshot",
|
|
115
|
+
"console_log",
|
|
116
|
+
"network_log",
|
|
117
|
+
"html_snapshot",
|
|
118
|
+
"custom"
|
|
119
|
+
]),
|
|
120
|
+
name: z3.string().min(1).max(100),
|
|
121
|
+
data: z3.string(),
|
|
122
|
+
// For screenshot: absolute file path; for others: content
|
|
123
|
+
metadata: z3.record(z3.unknown()).optional()
|
|
124
|
+
});
|
|
125
|
+
var CompleteRunParamsSchema = z3.object({
|
|
126
|
+
runId: z3.string().min(1),
|
|
127
|
+
status: z3.enum(["pass", "fail"]),
|
|
128
|
+
duration: z3.number().nonnegative(),
|
|
129
|
+
failedStep: z3.string().regex(/^\d{2}$/, "Step ID must be zero-padded").optional(),
|
|
130
|
+
// Changed from number to string
|
|
131
|
+
errorMessage: z3.string().optional(),
|
|
132
|
+
steps: z3.array(StepResultSchema).optional()
|
|
133
|
+
// NEW: per-step results
|
|
134
|
+
});
|
|
135
|
+
var CompleteStepParamsSchema = z3.object({
|
|
136
|
+
runId: z3.string().min(1),
|
|
137
|
+
stepId: z3.string().regex(/^\d{2}$/, 'Step ID must be zero-padded (e.g., "01", "02")'),
|
|
138
|
+
status: z3.enum(["pass", "fail", "skipped"]),
|
|
139
|
+
duration: z3.number().nonnegative(),
|
|
140
|
+
error: z3.string().optional(),
|
|
141
|
+
/** AI-generated summary describing what happened in this step and match result */
|
|
142
|
+
summary: z3.string().optional()
|
|
143
|
+
});
|
|
144
|
+
var GetStatusParamsSchema = z3.object({
|
|
145
|
+
scenarioSlug: z3.string().regex(/^[a-z0-9-]+$/).optional(),
|
|
146
|
+
starredOnly: z3.boolean().optional().default(false)
|
|
147
|
+
});
|
|
148
|
+
var OpenDashboardParamsSchema = z3.object({
|
|
149
|
+
port: z3.number().int().min(1024).max(65535).optional(),
|
|
150
|
+
openBrowser: z3.boolean().optional().default(true),
|
|
151
|
+
projectPath: z3.string().optional().describe(
|
|
152
|
+
"Path to the project directory containing .harshJudge folder. Defaults to current working directory."
|
|
153
|
+
)
|
|
154
|
+
});
|
|
155
|
+
var CloseDashboardParamsSchema = z3.object({
|
|
156
|
+
projectPath: z3.string().optional().describe(
|
|
157
|
+
"Path to the project directory containing .harshJudge folder. Defaults to current working directory."
|
|
158
|
+
)
|
|
159
|
+
});
|
|
160
|
+
var GetDashboardStatusParamsSchema = z3.object({
|
|
161
|
+
projectPath: z3.string().optional().describe(
|
|
162
|
+
"Path to the project directory containing .harshJudge folder. Defaults to current working directory."
|
|
163
|
+
)
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// src/services/file-system-service.ts
|
|
167
|
+
import { mkdir, writeFile, readFile, access, readdir } from "fs/promises";
|
|
168
|
+
import { dirname, join } from "path";
|
|
169
|
+
import yaml from "js-yaml";
|
|
170
|
+
var HARSH_JUDGE_DIR = ".harshJudge";
|
|
171
|
+
var SCENARIOS_DIR = "scenarios";
|
|
172
|
+
var STEPS_DIR = "steps";
|
|
173
|
+
var FileSystemService = class {
|
|
174
|
+
basePath;
|
|
175
|
+
constructor(basePath = process.cwd()) {
|
|
176
|
+
this.basePath = basePath;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolves a relative path against the base path.
|
|
180
|
+
*/
|
|
181
|
+
resolve(path) {
|
|
182
|
+
return join(this.basePath, path);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Creates a directory recursively if it doesn't exist.
|
|
186
|
+
*/
|
|
187
|
+
async ensureDir(path) {
|
|
188
|
+
await mkdir(this.resolve(path), { recursive: true });
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Checks if a path exists.
|
|
192
|
+
*/
|
|
193
|
+
async exists(path) {
|
|
194
|
+
try {
|
|
195
|
+
await access(this.resolve(path));
|
|
196
|
+
return true;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Writes an object as YAML to a file.
|
|
203
|
+
*/
|
|
204
|
+
async writeYaml(path, data) {
|
|
205
|
+
const content = yaml.dump(data, { indent: 2 });
|
|
206
|
+
await this.writeFile(path, content);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Reads and parses a YAML file.
|
|
210
|
+
*/
|
|
211
|
+
async readYaml(path) {
|
|
212
|
+
const content = await readFile(this.resolve(path), "utf-8");
|
|
213
|
+
return yaml.load(content);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Writes an object as JSON to a file.
|
|
217
|
+
*/
|
|
218
|
+
async writeJson(path, data) {
|
|
219
|
+
const content = JSON.stringify(data, null, 2);
|
|
220
|
+
await this.writeFile(path, content);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Reads and parses a JSON file.
|
|
224
|
+
*/
|
|
225
|
+
async readJson(path) {
|
|
226
|
+
const content = await readFile(this.resolve(path), "utf-8");
|
|
227
|
+
return JSON.parse(content);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Writes string or binary data to a file.
|
|
231
|
+
* Creates parent directories if they don't exist.
|
|
232
|
+
*/
|
|
233
|
+
async writeFile(path, data) {
|
|
234
|
+
const fullPath = this.resolve(path);
|
|
235
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
236
|
+
await writeFile(fullPath, data);
|
|
237
|
+
}
|
|
238
|
+
async readFile(path, binary) {
|
|
239
|
+
const isAbsolute = /^[A-Z]:[/\\]/i.test(path) || path.startsWith("/");
|
|
240
|
+
const fullPath = isAbsolute ? path : this.resolve(path);
|
|
241
|
+
if (binary) {
|
|
242
|
+
return readFile(fullPath);
|
|
243
|
+
}
|
|
244
|
+
return readFile(fullPath, "utf-8");
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Lists subdirectories in a directory.
|
|
248
|
+
*/
|
|
249
|
+
async listDirs(path) {
|
|
250
|
+
const entries = await readdir(this.resolve(path), { withFileTypes: true });
|
|
251
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Lists files (not directories) in a directory.
|
|
255
|
+
*/
|
|
256
|
+
async listFiles(path) {
|
|
257
|
+
const entries = await readdir(this.resolve(path), { withFileTypes: true });
|
|
258
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
259
|
+
}
|
|
260
|
+
// ============================================================
|
|
261
|
+
// Step File Operations (v2)
|
|
262
|
+
// ============================================================
|
|
263
|
+
/**
|
|
264
|
+
* Ensures steps directory exists for a scenario.
|
|
265
|
+
*/
|
|
266
|
+
async ensureStepsDir(scenarioSlug) {
|
|
267
|
+
const stepsPath = `${HARSH_JUDGE_DIR}/${SCENARIOS_DIR}/${scenarioSlug}/${STEPS_DIR}`;
|
|
268
|
+
await this.ensureDir(stepsPath);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Writes a step file to the scenario's steps directory.
|
|
272
|
+
*/
|
|
273
|
+
async writeStepFile(scenarioSlug, stepId, stepSlug, content) {
|
|
274
|
+
const filename = `${stepId}-${stepSlug}.md`;
|
|
275
|
+
const stepPath = `${HARSH_JUDGE_DIR}/${SCENARIOS_DIR}/${scenarioSlug}/${STEPS_DIR}/${filename}`;
|
|
276
|
+
await this.writeFile(stepPath, content);
|
|
277
|
+
return stepPath;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Reads a step file from the scenario's steps directory.
|
|
281
|
+
*/
|
|
282
|
+
async readStepFile(scenarioSlug, filename) {
|
|
283
|
+
const stepPath = `${HARSH_JUDGE_DIR}/${SCENARIOS_DIR}/${scenarioSlug}/${STEPS_DIR}/${filename}`;
|
|
284
|
+
return this.readFile(stepPath);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Lists step files in a scenario's steps directory.
|
|
288
|
+
*/
|
|
289
|
+
async listStepFiles(scenarioSlug) {
|
|
290
|
+
const stepsPath = `${HARSH_JUDGE_DIR}/${SCENARIOS_DIR}/${scenarioSlug}/${STEPS_DIR}`;
|
|
291
|
+
if (!await this.exists(stepsPath)) {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
const files = await this.listFiles(stepsPath);
|
|
295
|
+
return files.filter((f) => /^\d{2}-[\w-]+\.md$/.test(f)).sort();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Gets the steps directory path for a scenario.
|
|
299
|
+
*/
|
|
300
|
+
getStepsPath(scenarioSlug) {
|
|
301
|
+
return `${HARSH_JUDGE_DIR}/${SCENARIOS_DIR}/${scenarioSlug}/${STEPS_DIR}`;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
function generateStepMarkdown(step) {
|
|
305
|
+
return `# Step ${step.id}: ${step.title}
|
|
306
|
+
|
|
307
|
+
## Description
|
|
308
|
+
${step.description}
|
|
309
|
+
|
|
310
|
+
## Preconditions
|
|
311
|
+
${step.preconditions}
|
|
312
|
+
|
|
313
|
+
## Actions
|
|
314
|
+
${step.actions}
|
|
315
|
+
|
|
316
|
+
**Playwright:**
|
|
317
|
+
\`\`\`javascript
|
|
318
|
+
// Add Playwright code here
|
|
319
|
+
\`\`\`
|
|
320
|
+
|
|
321
|
+
## Expected Outcome
|
|
322
|
+
${step.expectedOutcome}
|
|
323
|
+
`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/services/dashboard-manager.ts
|
|
327
|
+
import { fork } from "child_process";
|
|
328
|
+
import { createServer } from "net";
|
|
329
|
+
import { existsSync, writeFileSync, unlinkSync, readFileSync, mkdirSync } from "fs";
|
|
330
|
+
import { join as join2, resolve, dirname as dirname2 } from "path";
|
|
331
|
+
import { fileURLToPath } from "url";
|
|
332
|
+
var HARSH_JUDGE_DIR2 = ".harshJudge";
|
|
333
|
+
var DASHBOARD_STATE_FILE = ".dashboard-state.json";
|
|
334
|
+
var DEFAULT_DASHBOARD_PORT = 7002;
|
|
335
|
+
async function isPortAvailable(port) {
|
|
336
|
+
return new Promise((resolve2) => {
|
|
337
|
+
const server = createServer();
|
|
338
|
+
server.once("error", () => resolve2(false));
|
|
339
|
+
server.once("listening", () => {
|
|
340
|
+
server.close();
|
|
341
|
+
resolve2(true);
|
|
342
|
+
});
|
|
343
|
+
server.listen(port);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
347
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
348
|
+
const port = startPort + i;
|
|
349
|
+
if (await isPortAvailable(port)) {
|
|
350
|
+
return port;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
throw new Error(`No available port found starting from ${startPort}`);
|
|
354
|
+
}
|
|
355
|
+
async function waitForServer(port, timeoutMs = 5e3) {
|
|
356
|
+
const startTime = Date.now();
|
|
357
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
358
|
+
if (!await isPortAvailable(port)) {
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
await new Promise((resolve2) => setTimeout(resolve2, 100));
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
function isProcessRunning(pid) {
|
|
366
|
+
try {
|
|
367
|
+
process.kill(pid, 0);
|
|
368
|
+
return true;
|
|
369
|
+
} catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async function killProcess(pid) {
|
|
374
|
+
try {
|
|
375
|
+
process.kill(pid, "SIGTERM");
|
|
376
|
+
await new Promise((resolve2) => setTimeout(resolve2, 300));
|
|
377
|
+
if (isProcessRunning(pid)) {
|
|
378
|
+
process.kill(pid, "SIGKILL");
|
|
379
|
+
}
|
|
380
|
+
return true;
|
|
381
|
+
} catch {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
var DashboardManager = class {
|
|
386
|
+
fs;
|
|
387
|
+
stateFilePath;
|
|
388
|
+
projectDir;
|
|
389
|
+
constructor(fs = new FileSystemService(), projectPath) {
|
|
390
|
+
this.fs = fs;
|
|
391
|
+
this.projectDir = projectPath ? resolve(projectPath) : process.cwd();
|
|
392
|
+
this.stateFilePath = join2(this.projectDir, HARSH_JUDGE_DIR2, DASHBOARD_STATE_FILE);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Get dashboard state file path.
|
|
396
|
+
*/
|
|
397
|
+
getStateFilePath() {
|
|
398
|
+
return this.stateFilePath;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Read the current dashboard state from file.
|
|
402
|
+
*/
|
|
403
|
+
readState() {
|
|
404
|
+
try {
|
|
405
|
+
if (existsSync(this.stateFilePath)) {
|
|
406
|
+
const content = readFileSync(this.stateFilePath, "utf-8");
|
|
407
|
+
return JSON.parse(content);
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Write dashboard state to file.
|
|
415
|
+
*/
|
|
416
|
+
writeState(state) {
|
|
417
|
+
const dir = dirname2(this.stateFilePath);
|
|
418
|
+
if (!existsSync(dir)) {
|
|
419
|
+
mkdirSync(dir, { recursive: true });
|
|
420
|
+
}
|
|
421
|
+
writeFileSync(this.stateFilePath, JSON.stringify(state, null, 2));
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Remove dashboard state file.
|
|
425
|
+
*/
|
|
426
|
+
clearState() {
|
|
427
|
+
try {
|
|
428
|
+
if (existsSync(this.stateFilePath)) {
|
|
429
|
+
unlinkSync(this.stateFilePath);
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Get the current dashboard status.
|
|
436
|
+
*/
|
|
437
|
+
async getStatus() {
|
|
438
|
+
const state = this.readState();
|
|
439
|
+
if (!state) {
|
|
440
|
+
return { running: false };
|
|
441
|
+
}
|
|
442
|
+
const processAlive = isProcessRunning(state.pid);
|
|
443
|
+
const portInUse = !await isPortAvailable(state.port);
|
|
444
|
+
if (processAlive && portInUse) {
|
|
445
|
+
return {
|
|
446
|
+
running: true,
|
|
447
|
+
pid: state.pid,
|
|
448
|
+
port: state.port,
|
|
449
|
+
url: state.url,
|
|
450
|
+
startedAt: state.startedAt
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
running: false,
|
|
455
|
+
pid: state.pid,
|
|
456
|
+
port: state.port,
|
|
457
|
+
stale: true
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Start the dashboard server.
|
|
462
|
+
*/
|
|
463
|
+
async start(preferredPort) {
|
|
464
|
+
const status = await this.getStatus();
|
|
465
|
+
if (status.running) {
|
|
466
|
+
throw new Error(`Dashboard already running on port ${status.port} (PID: ${status.pid}). Use closeDashboard first.`);
|
|
467
|
+
}
|
|
468
|
+
if (status.stale) {
|
|
469
|
+
this.clearState();
|
|
470
|
+
}
|
|
471
|
+
const startPort = preferredPort ?? DEFAULT_DASHBOARD_PORT;
|
|
472
|
+
const port = await findAvailablePort(startPort);
|
|
473
|
+
if (process.env["NODE_ENV"] === "test" || process.env["VITEST"]) {
|
|
474
|
+
const state2 = {
|
|
475
|
+
pid: 0,
|
|
476
|
+
port,
|
|
477
|
+
url: `http://localhost:${port}`,
|
|
478
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
479
|
+
projectPath: this.projectDir
|
|
480
|
+
};
|
|
481
|
+
this.writeState(state2);
|
|
482
|
+
return state2;
|
|
483
|
+
}
|
|
484
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
485
|
+
const __dirname2 = dirname2(__filename);
|
|
486
|
+
const workerPath = join2(__dirname2, "dashboard-worker.js");
|
|
487
|
+
console.error(`[HarshJudge] Starting dashboard on port ${port}`);
|
|
488
|
+
console.error(`[HarshJudge] Project directory: ${this.projectDir}`);
|
|
489
|
+
console.error(`[HarshJudge] Worker path: ${workerPath}`);
|
|
490
|
+
let child;
|
|
491
|
+
try {
|
|
492
|
+
child = fork(workerPath, [], {
|
|
493
|
+
cwd: this.projectDir,
|
|
494
|
+
detached: true,
|
|
495
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
496
|
+
env: {
|
|
497
|
+
...process.env,
|
|
498
|
+
HARSHJUDGE_PORT: String(port),
|
|
499
|
+
HARSHJUDGE_PROJECT_PATH: this.projectDir
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
} catch (err) {
|
|
503
|
+
throw new Error(`Failed to fork dashboard worker: ${err}`);
|
|
504
|
+
}
|
|
505
|
+
const pid = child.pid;
|
|
506
|
+
if (!pid) {
|
|
507
|
+
throw new Error("Failed to fork dashboard worker - no PID returned");
|
|
508
|
+
}
|
|
509
|
+
let startupError = "";
|
|
510
|
+
child.stderr?.on("data", (data) => {
|
|
511
|
+
const msg = data.toString();
|
|
512
|
+
console.error(`[Dashboard] ${msg}`);
|
|
513
|
+
if (msg.toLowerCase().includes("error")) {
|
|
514
|
+
startupError += msg;
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
child.stdout?.on("data", (data) => {
|
|
518
|
+
console.error(`[Dashboard] ${data.toString()}`);
|
|
519
|
+
});
|
|
520
|
+
const started = await waitForServer(port, 8e3);
|
|
521
|
+
child.disconnect?.();
|
|
522
|
+
child.unref();
|
|
523
|
+
if (!started) {
|
|
524
|
+
if (startupError) {
|
|
525
|
+
throw new Error(`Dashboard failed to start: ${startupError}`);
|
|
526
|
+
}
|
|
527
|
+
throw new Error(`Dashboard did not start within timeout on port ${port}`);
|
|
528
|
+
}
|
|
529
|
+
console.error(`[HarshJudge] Dashboard started successfully (PID: ${pid})`);
|
|
530
|
+
const state = {
|
|
531
|
+
pid,
|
|
532
|
+
port,
|
|
533
|
+
url: `http://localhost:${port}`,
|
|
534
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
535
|
+
projectPath: this.projectDir
|
|
536
|
+
};
|
|
537
|
+
this.writeState(state);
|
|
538
|
+
return state;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Stop the dashboard server.
|
|
542
|
+
*/
|
|
543
|
+
async stop() {
|
|
544
|
+
const status = await this.getStatus();
|
|
545
|
+
if (!status.running && !status.stale) {
|
|
546
|
+
return { stopped: false, message: "Dashboard is not running" };
|
|
547
|
+
}
|
|
548
|
+
if (status.stale) {
|
|
549
|
+
this.clearState();
|
|
550
|
+
return { stopped: true, message: "Cleaned up stale dashboard state (process was already dead)" };
|
|
551
|
+
}
|
|
552
|
+
if (status.pid) {
|
|
553
|
+
const killed = await killProcess(status.pid);
|
|
554
|
+
if (!killed) {
|
|
555
|
+
return { stopped: false, message: `Failed to kill process ${status.pid}` };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
this.clearState();
|
|
559
|
+
return { stopped: true, message: `Dashboard stopped (was running on port ${status.port})` };
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Restart the dashboard (stop then start).
|
|
563
|
+
*/
|
|
564
|
+
async restart(preferredPort) {
|
|
565
|
+
await this.stop();
|
|
566
|
+
return await this.start(preferredPort);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// src/handlers/init-project.ts
|
|
571
|
+
var HARSH_JUDGE_DIR3 = ".harshJudge";
|
|
572
|
+
var CONFIG_FILE = "config.yaml";
|
|
573
|
+
var SCENARIOS_DIR2 = "scenarios";
|
|
574
|
+
var GITIGNORE_FILE = ".gitignore";
|
|
575
|
+
var PRD_FILE = "prd.md";
|
|
576
|
+
var GITIGNORE_CONTENT = `# HarshJudge
|
|
577
|
+
# Ignore large evidence files in CI (per-step structure)
|
|
578
|
+
scenarios/*/runs/*/step-*/evidence/*.png
|
|
579
|
+
scenarios/*/runs/*/step-*/evidence/*.html
|
|
580
|
+
# Dashboard state (local only)
|
|
581
|
+
.dashboard-state.json
|
|
582
|
+
`;
|
|
583
|
+
var PRD_TEMPLATE_CONTENT = `# Project PRD
|
|
584
|
+
|
|
585
|
+
## Application Type
|
|
586
|
+
<!-- backend | fullstack | frontend | other -->
|
|
587
|
+
{app_type}
|
|
588
|
+
|
|
589
|
+
## Ports
|
|
590
|
+
| Service | Port |
|
|
591
|
+
|---------|------|
|
|
592
|
+
| Frontend | {frontend_port} |
|
|
593
|
+
| Backend | {backend_port} |
|
|
594
|
+
| Database | {database_port} |
|
|
595
|
+
|
|
596
|
+
## Main Scenarios
|
|
597
|
+
<!-- High-level list of main testing scenarios -->
|
|
598
|
+
- {scenario_1}
|
|
599
|
+
- {scenario_2}
|
|
600
|
+
- {scenario_3}
|
|
601
|
+
|
|
602
|
+
## Authentication
|
|
603
|
+
<!-- Auth requirements for testing -->
|
|
604
|
+
- **Login URL:** {login_url}
|
|
605
|
+
- **Test Credentials:**
|
|
606
|
+
- Username: {test_username}
|
|
607
|
+
- Password: {test_password}
|
|
608
|
+
|
|
609
|
+
## Tech Stack
|
|
610
|
+
<!-- Frameworks, libraries, tools -->
|
|
611
|
+
- Frontend: {frontend_stack}
|
|
612
|
+
- Backend: {backend_stack}
|
|
613
|
+
- Testing: {testing_tools}
|
|
614
|
+
|
|
615
|
+
## Notes
|
|
616
|
+
<!-- Additional context for test scenarios -->
|
|
617
|
+
- {note_1}
|
|
618
|
+
- {note_2}
|
|
619
|
+
`;
|
|
620
|
+
async function handleInitProject(params, fs = new FileSystemService()) {
|
|
621
|
+
const validated = InitProjectParamsSchema.parse(params);
|
|
622
|
+
if (await fs.exists(HARSH_JUDGE_DIR3)) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
"Project already initialized. Use a different directory or remove existing .harshJudge folder."
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
await fs.ensureDir(HARSH_JUDGE_DIR3);
|
|
628
|
+
await fs.ensureDir(`${HARSH_JUDGE_DIR3}/${SCENARIOS_DIR2}`);
|
|
629
|
+
const config = {
|
|
630
|
+
projectName: validated.projectName,
|
|
631
|
+
baseUrl: validated.baseUrl ?? "",
|
|
632
|
+
version: "1.0",
|
|
633
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
634
|
+
};
|
|
635
|
+
const configPath = `${HARSH_JUDGE_DIR3}/${CONFIG_FILE}`;
|
|
636
|
+
await fs.writeYaml(configPath, config);
|
|
637
|
+
const gitignorePath = `${HARSH_JUDGE_DIR3}/${GITIGNORE_FILE}`;
|
|
638
|
+
await fs.writeFile(gitignorePath, GITIGNORE_CONTENT);
|
|
639
|
+
const prdPath = `${HARSH_JUDGE_DIR3}/${PRD_FILE}`;
|
|
640
|
+
await fs.writeFile(prdPath, PRD_TEMPLATE_CONTENT);
|
|
641
|
+
let dashboardUrl;
|
|
642
|
+
let message = "HarshJudge initialized successfully";
|
|
643
|
+
try {
|
|
644
|
+
const manager = new DashboardManager(fs);
|
|
645
|
+
const state = await manager.start();
|
|
646
|
+
dashboardUrl = state.url;
|
|
647
|
+
message = `HarshJudge initialized successfully. Dashboard running at ${dashboardUrl} (PID: ${state.pid})`;
|
|
648
|
+
console.error(`[HarshJudge] ${message}`);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
console.error(`[HarshJudge] Warning: Could not start dashboard: ${error}`);
|
|
651
|
+
message = `HarshJudge initialized successfully. Dashboard could not be started automatically - use openDashboard tool to start it.`;
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
success: true,
|
|
655
|
+
projectPath: HARSH_JUDGE_DIR3,
|
|
656
|
+
configPath,
|
|
657
|
+
prdPath,
|
|
658
|
+
scenariosPath: `${HARSH_JUDGE_DIR3}/${SCENARIOS_DIR2}`,
|
|
659
|
+
dashboardUrl,
|
|
660
|
+
message
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/utils/cli-helpers.ts
|
|
665
|
+
function withErrorHandling(fn) {
|
|
666
|
+
return async (...args) => {
|
|
667
|
+
try {
|
|
668
|
+
await fn(...args);
|
|
669
|
+
} catch (error) {
|
|
670
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
671
|
+
console.error(JSON.stringify({ error: message }));
|
|
672
|
+
process.exit(1);
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// src/commands/init.ts
|
|
678
|
+
function register(program2) {
|
|
679
|
+
program2.command("init <name>").description("Initialize a HarshJudge project in the current directory").option("--base-url <url>", "Base URL for the application under test").action(
|
|
680
|
+
withErrorHandling(
|
|
681
|
+
async (name, opts, cmd) => {
|
|
682
|
+
const cwd = cmd.parent?.opts()["cwd"] ?? process.cwd();
|
|
683
|
+
const fs = new FileSystemService(cwd);
|
|
684
|
+
const result = await handleInitProject(
|
|
685
|
+
{ projectName: name, baseUrl: opts.baseUrl },
|
|
686
|
+
fs
|
|
687
|
+
);
|
|
688
|
+
console.log(JSON.stringify(result));
|
|
689
|
+
}
|
|
690
|
+
)
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// src/commands/create.ts
|
|
695
|
+
import "commander";
|
|
696
|
+
|
|
697
|
+
// src/utils/slugify.ts
|
|
698
|
+
function slugify(text) {
|
|
699
|
+
return text.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/handlers/create-scenario.ts
|
|
703
|
+
var HARSH_JUDGE_DIR4 = ".harshJudge";
|
|
704
|
+
var SCENARIOS_DIR3 = "scenarios";
|
|
705
|
+
var STEPS_DIR2 = "steps";
|
|
706
|
+
var META_FILE = "meta.yaml";
|
|
707
|
+
function generateStepContent(stepId, step) {
|
|
708
|
+
return generateStepMarkdown({
|
|
709
|
+
id: stepId,
|
|
710
|
+
title: step.title,
|
|
711
|
+
description: step.description || "",
|
|
712
|
+
preconditions: step.preconditions || "",
|
|
713
|
+
actions: step.actions,
|
|
714
|
+
expectedOutcome: step.expectedOutcome
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
async function handleCreateScenario(params, fs = new FileSystemService()) {
|
|
718
|
+
const validated = CreateScenarioParamsSchema.parse(params);
|
|
719
|
+
if (!await fs.exists(HARSH_JUDGE_DIR4)) {
|
|
720
|
+
throw new Error("Project not initialized. Run initProject first.");
|
|
721
|
+
}
|
|
722
|
+
const scenarioPath = `${HARSH_JUDGE_DIR4}/${SCENARIOS_DIR3}/${validated.slug}`;
|
|
723
|
+
const stepsPath = `${scenarioPath}/${STEPS_DIR2}`;
|
|
724
|
+
const metaPath = `${scenarioPath}/${META_FILE}`;
|
|
725
|
+
const isNew = !await fs.exists(scenarioPath);
|
|
726
|
+
await fs.ensureDir(stepsPath);
|
|
727
|
+
if (!isNew) {
|
|
728
|
+
const existingStepFiles = await fs.listStepFiles(validated.slug);
|
|
729
|
+
const newStepCount = validated.steps.length;
|
|
730
|
+
for (const file of existingStepFiles) {
|
|
731
|
+
const stepNum = parseInt(file.substring(0, 2), 10);
|
|
732
|
+
if (stepNum > newStepCount) {
|
|
733
|
+
const orphanPath = `${stepsPath}/${file}`;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const stepFiles = [];
|
|
738
|
+
const stepRefs = [];
|
|
739
|
+
for (let i = 0; i < validated.steps.length; i++) {
|
|
740
|
+
const step = validated.steps[i];
|
|
741
|
+
const stepId = padStepId(i + 1);
|
|
742
|
+
const stepSlug = slugify(step.title);
|
|
743
|
+
const filename = `${stepId}-${stepSlug}.md`;
|
|
744
|
+
const stepFilePath = `${stepsPath}/${filename}`;
|
|
745
|
+
const content = generateStepContent(stepId, step);
|
|
746
|
+
await fs.writeFile(stepFilePath, content);
|
|
747
|
+
stepFiles.push(stepFilePath);
|
|
748
|
+
stepRefs.push({
|
|
749
|
+
id: stepId,
|
|
750
|
+
title: step.title,
|
|
751
|
+
file: filename
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
const existingMeta = isNew ? null : await loadExistingMeta(fs, metaPath);
|
|
755
|
+
const meta = {
|
|
756
|
+
// Definition fields
|
|
757
|
+
title: validated.title,
|
|
758
|
+
slug: validated.slug,
|
|
759
|
+
starred: validated.starred,
|
|
760
|
+
tags: validated.tags,
|
|
761
|
+
estimatedDuration: validated.estimatedDuration,
|
|
762
|
+
steps: stepRefs,
|
|
763
|
+
// Statistics (preserve existing or initialize)
|
|
764
|
+
...DEFAULT_SCENARIO_STATS,
|
|
765
|
+
...existingMeta ? {
|
|
766
|
+
totalRuns: existingMeta.totalRuns,
|
|
767
|
+
passCount: existingMeta.passCount,
|
|
768
|
+
failCount: existingMeta.failCount,
|
|
769
|
+
lastRun: existingMeta.lastRun,
|
|
770
|
+
lastResult: existingMeta.lastResult,
|
|
771
|
+
avgDuration: existingMeta.avgDuration
|
|
772
|
+
} : {}
|
|
773
|
+
};
|
|
774
|
+
await fs.writeYaml(metaPath, meta);
|
|
775
|
+
return {
|
|
776
|
+
success: true,
|
|
777
|
+
slug: validated.slug,
|
|
778
|
+
scenarioPath,
|
|
779
|
+
metaPath,
|
|
780
|
+
stepsPath,
|
|
781
|
+
stepFiles,
|
|
782
|
+
isNew
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
async function loadExistingMeta(fs, metaPath) {
|
|
786
|
+
try {
|
|
787
|
+
if (await fs.exists(metaPath)) {
|
|
788
|
+
return await fs.readYaml(metaPath);
|
|
789
|
+
}
|
|
790
|
+
} catch {
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// src/commands/create.ts
|
|
796
|
+
function register2(program2) {
|
|
797
|
+
program2.command("create <slug>").description("Create a new test scenario").requiredOption("--title <title>", "Scenario title").requiredOption("--steps <json>", "Steps as a JSON string").option("--tags <tags>", "Comma-separated list of tags").option("--estimated-duration <seconds>", "Estimated duration in seconds").option("--starred", "Mark scenario as starred").action(
|
|
798
|
+
withErrorHandling(
|
|
799
|
+
async (slug, opts, cmd) => {
|
|
800
|
+
const cwd = cmd.parent?.opts()["cwd"] ?? process.cwd();
|
|
801
|
+
const fs = new FileSystemService(cwd);
|
|
802
|
+
const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim()) : void 0;
|
|
803
|
+
const result = await handleCreateScenario(
|
|
804
|
+
{
|
|
805
|
+
slug,
|
|
806
|
+
title: opts.title,
|
|
807
|
+
steps: JSON.parse(opts.steps),
|
|
808
|
+
tags,
|
|
809
|
+
estimatedDuration: opts.estimatedDuration ? parseInt(opts.estimatedDuration, 10) : void 0,
|
|
810
|
+
starred: opts.starred
|
|
811
|
+
},
|
|
812
|
+
fs
|
|
813
|
+
);
|
|
814
|
+
console.log(JSON.stringify(result));
|
|
815
|
+
}
|
|
816
|
+
)
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/commands/star.ts
|
|
821
|
+
import "commander";
|
|
822
|
+
|
|
823
|
+
// src/handlers/toggle-star.ts
|
|
824
|
+
var HARSH_JUDGE_DIR5 = ".harshJudge";
|
|
825
|
+
var SCENARIOS_DIR4 = "scenarios";
|
|
826
|
+
var META_FILE2 = "meta.yaml";
|
|
827
|
+
async function handleToggleStar(params, fs = new FileSystemService()) {
|
|
828
|
+
const validated = ToggleStarParamsSchema.parse(params);
|
|
829
|
+
if (!await fs.exists(HARSH_JUDGE_DIR5)) {
|
|
830
|
+
throw new Error("Project not initialized. Run initProject first.");
|
|
831
|
+
}
|
|
832
|
+
const scenarioPath = `${HARSH_JUDGE_DIR5}/${SCENARIOS_DIR4}/${validated.scenarioSlug}`;
|
|
833
|
+
const metaPath = `${scenarioPath}/${META_FILE2}`;
|
|
834
|
+
if (!await fs.exists(scenarioPath)) {
|
|
835
|
+
throw new Error(`Scenario "${validated.scenarioSlug}" does not exist.`);
|
|
836
|
+
}
|
|
837
|
+
let meta;
|
|
838
|
+
if (await fs.exists(metaPath)) {
|
|
839
|
+
meta = await fs.readYaml(metaPath);
|
|
840
|
+
} else {
|
|
841
|
+
throw new Error(`Scenario "${validated.scenarioSlug}" has no meta.yaml.`);
|
|
842
|
+
}
|
|
843
|
+
const newStarred = validated.starred !== void 0 ? validated.starred : !meta.starred;
|
|
844
|
+
meta.starred = newStarred;
|
|
845
|
+
await fs.writeYaml(metaPath, meta);
|
|
846
|
+
return {
|
|
847
|
+
success: true,
|
|
848
|
+
slug: validated.scenarioSlug,
|
|
849
|
+
starred: newStarred
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// src/commands/star.ts
|
|
854
|
+
function register3(program2) {
|
|
855
|
+
program2.command("star <slug>").description("Star or unstar a scenario").option("--unstar", "Remove star from scenario").action(
|
|
856
|
+
withErrorHandling(
|
|
857
|
+
async (slug, opts, cmd) => {
|
|
858
|
+
const cwd = cmd.parent?.opts()["cwd"] ?? process.cwd();
|
|
859
|
+
const fs = new FileSystemService(cwd);
|
|
860
|
+
const result = await handleToggleStar(
|
|
861
|
+
{ scenarioSlug: slug, starred: !opts.unstar },
|
|
862
|
+
fs
|
|
863
|
+
);
|
|
864
|
+
console.log(JSON.stringify(result));
|
|
865
|
+
}
|
|
866
|
+
)
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/commands/status.ts
|
|
871
|
+
import "commander";
|
|
872
|
+
|
|
873
|
+
// src/handlers/get-status.ts
|
|
874
|
+
var HARSH_JUDGE_DIR6 = ".harshJudge";
|
|
875
|
+
var SCENARIOS_DIR5 = "scenarios";
|
|
876
|
+
var CONFIG_FILE2 = "config.yaml";
|
|
877
|
+
var SCENARIO_FILE = "scenario.md";
|
|
878
|
+
var META_FILE3 = "meta.yaml";
|
|
879
|
+
var RUNS_DIR = "runs";
|
|
880
|
+
var RESULT_FILE = "result.json";
|
|
881
|
+
function parseFrontmatter(content) {
|
|
882
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
883
|
+
if (!match || !match[1]) {
|
|
884
|
+
return { title: "Untitled", tags: [] };
|
|
885
|
+
}
|
|
886
|
+
const frontmatter = match[1];
|
|
887
|
+
let title = "Untitled";
|
|
888
|
+
let tags = [];
|
|
889
|
+
const titleMatch = frontmatter.match(/^title:\s*(.+)$/m);
|
|
890
|
+
if (titleMatch && titleMatch[1]) {
|
|
891
|
+
title = titleMatch[1].trim();
|
|
892
|
+
}
|
|
893
|
+
const tagsMatch = frontmatter.match(/^tags:\s*\[([^\]]*)\]/m);
|
|
894
|
+
if (tagsMatch && tagsMatch[1] !== void 0) {
|
|
895
|
+
tags = tagsMatch[1].split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
896
|
+
}
|
|
897
|
+
return { title, tags };
|
|
898
|
+
}
|
|
899
|
+
function getContentWithoutFrontmatter(content) {
|
|
900
|
+
return content.replace(/^---\n[\s\S]*?\n---\n*/, "");
|
|
901
|
+
}
|
|
902
|
+
async function getProjectStatus(fs, starredOnly = false) {
|
|
903
|
+
const configPath = `${HARSH_JUDGE_DIR6}/${CONFIG_FILE2}`;
|
|
904
|
+
const config = await fs.readYaml(configPath);
|
|
905
|
+
const scenariosPath = `${HARSH_JUDGE_DIR6}/${SCENARIOS_DIR5}`;
|
|
906
|
+
const scenarioSlugs = await fs.listDirs(scenariosPath);
|
|
907
|
+
const scenarios = [];
|
|
908
|
+
let passing = 0;
|
|
909
|
+
let failing = 0;
|
|
910
|
+
let neverRun = 0;
|
|
911
|
+
for (const slug of scenarioSlugs) {
|
|
912
|
+
const scenarioPath = `${scenariosPath}/${slug}`;
|
|
913
|
+
const metaPath = `${scenarioPath}/${META_FILE3}`;
|
|
914
|
+
let meta;
|
|
915
|
+
let title = "Untitled";
|
|
916
|
+
let tags = [];
|
|
917
|
+
let starred = false;
|
|
918
|
+
let stepCount = 0;
|
|
919
|
+
if (await fs.exists(metaPath)) {
|
|
920
|
+
meta = await fs.readYaml(metaPath);
|
|
921
|
+
if (meta.title) {
|
|
922
|
+
title = meta.title;
|
|
923
|
+
}
|
|
924
|
+
starred = meta.starred ?? false;
|
|
925
|
+
stepCount = meta.steps?.length ?? 0;
|
|
926
|
+
} else {
|
|
927
|
+
meta = {
|
|
928
|
+
totalRuns: 0,
|
|
929
|
+
passCount: 0,
|
|
930
|
+
failCount: 0,
|
|
931
|
+
lastRun: null,
|
|
932
|
+
lastResult: null,
|
|
933
|
+
avgDuration: 0
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
const scenarioFilePath = `${scenarioPath}/${SCENARIO_FILE}`;
|
|
937
|
+
if (await fs.exists(scenarioFilePath)) {
|
|
938
|
+
const scenarioContent = await fs.readFile(scenarioFilePath);
|
|
939
|
+
const parsed = parseFrontmatter(scenarioContent);
|
|
940
|
+
if (!meta.title) {
|
|
941
|
+
title = parsed.title;
|
|
942
|
+
}
|
|
943
|
+
tags = parsed.tags;
|
|
944
|
+
}
|
|
945
|
+
if (starredOnly && !starred) {
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const passRate = meta.totalRuns > 0 ? Math.round(meta.passCount / meta.totalRuns * 100) : 0;
|
|
949
|
+
if (meta.totalRuns === 0) {
|
|
950
|
+
neverRun++;
|
|
951
|
+
} else if (meta.lastResult === "pass") {
|
|
952
|
+
passing++;
|
|
953
|
+
} else {
|
|
954
|
+
failing++;
|
|
955
|
+
}
|
|
956
|
+
scenarios.push({
|
|
957
|
+
slug,
|
|
958
|
+
title,
|
|
959
|
+
starred,
|
|
960
|
+
tags,
|
|
961
|
+
stepCount,
|
|
962
|
+
lastResult: meta.lastResult,
|
|
963
|
+
lastRun: meta.lastRun,
|
|
964
|
+
totalRuns: meta.totalRuns,
|
|
965
|
+
passRate
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
projectName: config.projectName,
|
|
970
|
+
scenarioCount: scenarios.length,
|
|
971
|
+
passing,
|
|
972
|
+
failing,
|
|
973
|
+
neverRun,
|
|
974
|
+
scenarios
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
async function getScenarioDetail(fs, slug) {
|
|
978
|
+
const scenarioPath = `${HARSH_JUDGE_DIR6}/${SCENARIOS_DIR5}/${slug}`;
|
|
979
|
+
if (!await fs.exists(scenarioPath)) {
|
|
980
|
+
throw new Error(`Scenario "${slug}" does not exist.`);
|
|
981
|
+
}
|
|
982
|
+
const metaPath = `${scenarioPath}/${META_FILE3}`;
|
|
983
|
+
let meta;
|
|
984
|
+
let title = "Untitled";
|
|
985
|
+
let tags = [];
|
|
986
|
+
let starred = false;
|
|
987
|
+
let stepCount = 0;
|
|
988
|
+
let content = "";
|
|
989
|
+
if (await fs.exists(metaPath)) {
|
|
990
|
+
meta = await fs.readYaml(metaPath);
|
|
991
|
+
if (meta.title) {
|
|
992
|
+
title = meta.title;
|
|
993
|
+
}
|
|
994
|
+
starred = meta.starred ?? false;
|
|
995
|
+
stepCount = meta.steps?.length ?? 0;
|
|
996
|
+
} else {
|
|
997
|
+
meta = {
|
|
998
|
+
totalRuns: 0,
|
|
999
|
+
passCount: 0,
|
|
1000
|
+
failCount: 0,
|
|
1001
|
+
lastRun: null,
|
|
1002
|
+
lastResult: null,
|
|
1003
|
+
avgDuration: 0
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
const scenarioFilePath = `${scenarioPath}/${SCENARIO_FILE}`;
|
|
1007
|
+
if (await fs.exists(scenarioFilePath)) {
|
|
1008
|
+
const scenarioContent = await fs.readFile(scenarioFilePath);
|
|
1009
|
+
const parsed = parseFrontmatter(scenarioContent);
|
|
1010
|
+
if (!meta.title) {
|
|
1011
|
+
title = parsed.title;
|
|
1012
|
+
}
|
|
1013
|
+
tags = parsed.tags;
|
|
1014
|
+
content = getContentWithoutFrontmatter(scenarioContent);
|
|
1015
|
+
}
|
|
1016
|
+
const runsPath = `${scenarioPath}/${RUNS_DIR}`;
|
|
1017
|
+
const recentRuns = [];
|
|
1018
|
+
if (await fs.exists(runsPath)) {
|
|
1019
|
+
const runIds = await fs.listDirs(runsPath);
|
|
1020
|
+
const runResults = [];
|
|
1021
|
+
for (const id of runIds) {
|
|
1022
|
+
const resultPath = `${runsPath}/${id}/${RESULT_FILE}`;
|
|
1023
|
+
if (await fs.exists(resultPath)) {
|
|
1024
|
+
const result = await fs.readJson(resultPath);
|
|
1025
|
+
runResults.push({ id, result });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
runResults.sort(
|
|
1029
|
+
(a, b) => new Date(b.result.completedAt).getTime() - new Date(a.result.completedAt).getTime()
|
|
1030
|
+
);
|
|
1031
|
+
for (let i = 0; i < Math.min(10, runResults.length); i++) {
|
|
1032
|
+
const runData = runResults[i];
|
|
1033
|
+
if (runData) {
|
|
1034
|
+
const { id, result } = runData;
|
|
1035
|
+
recentRuns.push({
|
|
1036
|
+
id,
|
|
1037
|
+
runNumber: runResults.length - i,
|
|
1038
|
+
// Approximate run number
|
|
1039
|
+
status: result.status,
|
|
1040
|
+
duration: result.duration,
|
|
1041
|
+
completedAt: result.completedAt,
|
|
1042
|
+
errorMessage: result.errorMessage || null
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return {
|
|
1048
|
+
slug,
|
|
1049
|
+
title,
|
|
1050
|
+
starred,
|
|
1051
|
+
tags,
|
|
1052
|
+
stepCount,
|
|
1053
|
+
content,
|
|
1054
|
+
meta,
|
|
1055
|
+
recentRuns
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
async function handleGetStatus(params, fs = new FileSystemService()) {
|
|
1059
|
+
const validated = GetStatusParamsSchema.parse(params);
|
|
1060
|
+
if (!await fs.exists(HARSH_JUDGE_DIR6)) {
|
|
1061
|
+
throw new Error("Project not initialized. Run initProject first.");
|
|
1062
|
+
}
|
|
1063
|
+
if (validated.scenarioSlug) {
|
|
1064
|
+
return getScenarioDetail(fs, validated.scenarioSlug);
|
|
1065
|
+
} else {
|
|
1066
|
+
return getProjectStatus(fs, validated.starredOnly);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// src/commands/status.ts
|
|
1071
|
+
function register4(program2) {
|
|
1072
|
+
program2.command("status [slug]").description("Get project or scenario status").option("--starred-only", "Show only starred scenarios").action(
|
|
1073
|
+
withErrorHandling(
|
|
1074
|
+
async (slug, opts, cmd) => {
|
|
1075
|
+
const cwd = cmd.parent?.opts()["cwd"] ?? process.cwd();
|
|
1076
|
+
const fs = new FileSystemService(cwd);
|
|
1077
|
+
const result = await handleGetStatus(
|
|
1078
|
+
{ scenarioSlug: slug, starredOnly: opts.starredOnly },
|
|
1079
|
+
fs
|
|
1080
|
+
);
|
|
1081
|
+
console.log(JSON.stringify(result));
|
|
1082
|
+
}
|
|
1083
|
+
)
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// src/commands/start.ts
|
|
1088
|
+
import "commander";
|
|
1089
|
+
|
|
1090
|
+
// src/handlers/start-run.ts
|
|
1091
|
+
import { nanoid } from "nanoid";
|
|
1092
|
+
var HARSH_JUDGE_DIR7 = ".harshJudge";
|
|
1093
|
+
var SCENARIOS_DIR6 = "scenarios";
|
|
1094
|
+
var RUNS_DIR2 = "runs";
|
|
1095
|
+
var EVIDENCE_DIR = "evidence";
|
|
1096
|
+
var META_FILE4 = "meta.yaml";
|
|
1097
|
+
async function handleStartRun(params, fs = new FileSystemService()) {
|
|
1098
|
+
const validated = StartRunParamsSchema.parse(params);
|
|
1099
|
+
if (!await fs.exists(HARSH_JUDGE_DIR7)) {
|
|
1100
|
+
throw new Error("Project not initialized. Run initProject first.");
|
|
1101
|
+
}
|
|
1102
|
+
const scenarioDir = `${HARSH_JUDGE_DIR7}/${SCENARIOS_DIR6}/${validated.scenarioSlug}`;
|
|
1103
|
+
if (!await fs.exists(scenarioDir)) {
|
|
1104
|
+
throw new Error(`Scenario "${validated.scenarioSlug}" does not exist.`);
|
|
1105
|
+
}
|
|
1106
|
+
const metaPath = `${scenarioDir}/${META_FILE4}`;
|
|
1107
|
+
if (!await fs.exists(metaPath)) {
|
|
1108
|
+
throw new Error(`Scenario "${validated.scenarioSlug}" has no meta.yaml.`);
|
|
1109
|
+
}
|
|
1110
|
+
const meta = await fs.readYaml(metaPath);
|
|
1111
|
+
const steps = (meta.steps || []).map((step) => ({
|
|
1112
|
+
id: step.id,
|
|
1113
|
+
title: step.title,
|
|
1114
|
+
file: step.file
|
|
1115
|
+
}));
|
|
1116
|
+
const runId = nanoid(10);
|
|
1117
|
+
const runsDir = `${scenarioDir}/${RUNS_DIR2}`;
|
|
1118
|
+
let runNumber = 1;
|
|
1119
|
+
if (await fs.exists(runsDir)) {
|
|
1120
|
+
const existingRuns = await fs.listDirs(runsDir);
|
|
1121
|
+
runNumber = existingRuns.length + 1;
|
|
1122
|
+
}
|
|
1123
|
+
const runPath = `${runsDir}/${runId}`;
|
|
1124
|
+
const evidencePath = `${runPath}/${EVIDENCE_DIR}`;
|
|
1125
|
+
await fs.ensureDir(runPath);
|
|
1126
|
+
await fs.ensureDir(evidencePath);
|
|
1127
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1128
|
+
return {
|
|
1129
|
+
success: true,
|
|
1130
|
+
runId,
|
|
1131
|
+
runNumber,
|
|
1132
|
+
runPath,
|
|
1133
|
+
evidencePath,
|
|
1134
|
+
startedAt,
|
|
1135
|
+
scenarioSlug: validated.scenarioSlug,
|
|
1136
|
+
scenarioTitle: meta.title,
|
|
1137
|
+
steps
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// src/commands/start.ts
|
|
1142
|
+
function register5(program2) {
|
|
1143
|
+
program2.command("start <slug>").description("Start a new test run for a scenario").action(
|
|
1144
|
+
withErrorHandling(async (slug, _opts, cmd) => {
|
|
1145
|
+
const cwd = cmd.parent?.opts()["cwd"] ?? process.cwd();
|
|
1146
|
+
const fs = new FileSystemService(cwd);
|
|
1147
|
+
const result = await handleStartRun({ scenarioSlug: slug }, fs);
|
|
1148
|
+
console.log(JSON.stringify(result));
|
|
1149
|
+
})
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// src/commands/evidence.ts
|
|
1154
|
+
import "commander";
|
|
1155
|
+
|
|
1156
|
+
// src/handlers/record-evidence.ts
|
|
1157
|
+
var HARSH_JUDGE_DIR8 = ".harshJudge";
|
|
1158
|
+
var SCENARIOS_DIR7 = "scenarios";
|
|
1159
|
+
var RUNS_DIR3 = "runs";
|
|
1160
|
+
var RESULT_FILE2 = "result.json";
|
|
1161
|
+
var EVIDENCE_EXTENSIONS = {
|
|
1162
|
+
screenshot: "png",
|
|
1163
|
+
db_snapshot: "json",
|
|
1164
|
+
console_log: "txt",
|
|
1165
|
+
network_log: "json",
|
|
1166
|
+
html_snapshot: "html",
|
|
1167
|
+
custom: "json"
|
|
1168
|
+
};
|
|
1169
|
+
var BINARY_TYPES = /* @__PURE__ */ new Set(["screenshot"]);
|
|
1170
|
+
function isAbsoluteFilePath(data) {
|
|
1171
|
+
if (/^[A-Z]:[/\\]/i.test(data)) {
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
if (data.startsWith("/")) {
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
async function findRunDirectory(fs, runId) {
|
|
1180
|
+
const scenariosPath = `${HARSH_JUDGE_DIR8}/${SCENARIOS_DIR7}`;
|
|
1181
|
+
if (!await fs.exists(scenariosPath)) {
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
const scenarios = await fs.listDirs(scenariosPath);
|
|
1185
|
+
for (const scenario of scenarios) {
|
|
1186
|
+
const runPath = `${scenariosPath}/${scenario}/${RUNS_DIR3}/${runId}`;
|
|
1187
|
+
if (await fs.exists(runPath)) {
|
|
1188
|
+
return runPath;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
async function isRunCompleted(fs, runPath) {
|
|
1194
|
+
const resultPath = `${runPath}/${RESULT_FILE2}`;
|
|
1195
|
+
if (!await fs.exists(resultPath)) {
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
const result = await fs.readJson(resultPath);
|
|
1199
|
+
return result.status === "pass" || result.status === "fail";
|
|
1200
|
+
}
|
|
1201
|
+
async function handleRecordEvidence(params, fs = new FileSystemService()) {
|
|
1202
|
+
const validated = RecordEvidenceParamsSchema.parse(params);
|
|
1203
|
+
if (!await fs.exists(HARSH_JUDGE_DIR8)) {
|
|
1204
|
+
throw new Error("Project not initialized. Run initProject first.");
|
|
1205
|
+
}
|
|
1206
|
+
const runPath = await findRunDirectory(fs, validated.runId);
|
|
1207
|
+
if (!runPath) {
|
|
1208
|
+
throw new Error(`Run "${validated.runId}" does not exist.`);
|
|
1209
|
+
}
|
|
1210
|
+
if (await isRunCompleted(fs, runPath)) {
|
|
1211
|
+
throw new Error(`Run "${validated.runId}" is already completed. Cannot add evidence.`);
|
|
1212
|
+
}
|
|
1213
|
+
const stepId = String(validated.step).padStart(2, "0");
|
|
1214
|
+
const extension = EVIDENCE_EXTENSIONS[validated.type] || "bin";
|
|
1215
|
+
const fileName = `${validated.name}.${extension}`;
|
|
1216
|
+
const metaFileName = `${validated.name}.meta.json`;
|
|
1217
|
+
const stepPath = `${runPath}/step-${stepId}`;
|
|
1218
|
+
const evidencePath = `${stepPath}/evidence`;
|
|
1219
|
+
const filePath = `${evidencePath}/${fileName}`;
|
|
1220
|
+
const metaPath = `${evidencePath}/${metaFileName}`;
|
|
1221
|
+
let dataToWrite;
|
|
1222
|
+
let fileSize;
|
|
1223
|
+
if (BINARY_TYPES.has(validated.type)) {
|
|
1224
|
+
if (!isAbsoluteFilePath(validated.data)) {
|
|
1225
|
+
throw new Error(
|
|
1226
|
+
`For type="${validated.type}", data must be an absolute file path to the image file. Got: "${validated.data.substring(0, 50)}${validated.data.length > 50 ? "..." : ""}". Use the file path from Playwright's browser_take_screenshot tool.`
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
dataToWrite = await fs.readFile(validated.data, true);
|
|
1231
|
+
fileSize = dataToWrite.length;
|
|
1232
|
+
} catch {
|
|
1233
|
+
throw new Error(`Cannot read screenshot file: ${validated.data}`);
|
|
1234
|
+
}
|
|
1235
|
+
} else {
|
|
1236
|
+
dataToWrite = validated.data;
|
|
1237
|
+
fileSize = Buffer.byteLength(validated.data, "utf-8");
|
|
1238
|
+
}
|
|
1239
|
+
await fs.ensureDir(evidencePath);
|
|
1240
|
+
await fs.writeFile(filePath, dataToWrite);
|
|
1241
|
+
const metadata = {
|
|
1242
|
+
runId: validated.runId,
|
|
1243
|
+
stepId,
|
|
1244
|
+
type: validated.type,
|
|
1245
|
+
name: validated.name,
|
|
1246
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1247
|
+
fileSize,
|
|
1248
|
+
metadata: validated.metadata || {}
|
|
1249
|
+
};
|
|
1250
|
+
await fs.writeJson(metaPath, metadata);
|
|
1251
|
+
return {
|
|
1252
|
+
success: true,
|
|
1253
|
+
filePath,
|
|
1254
|
+
metaPath,
|
|
1255
|
+
stepPath,
|
|
1256
|
+
fileSize
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// src/commands/evidence.ts
|
|
1261
|
+
function register6(program2) {
|
|
1262
|
+
program2.command("evidence <runId>").description("Record evidence for a test run step").option("--step <n>", "Step number").option(
|
|
1263
|
+
"--type <type>",
|
|
1264
|
+
"Evidence type (screenshot, db_snapshot, console_log, etc.)"
|
|
1265
|
+
).option("--name <name>", "Evidence name").option("--data <data>", "Evidence data or file path").action(
|
|
1266
|
+
withErrorHandling(
|
|
1267
|
+
async (runId, opts, cmd) => {
|
|
1268
|
+
const cwd = cmd.parent?.opts()["cwd"] ?? process.cwd();
|
|
1269
|
+
const fs = new FileSystemService(cwd);
|
|
1270
|
+
const result = await handleRecordEvidence(
|
|
1271
|
+
{
|
|
1272
|
+
runId,
|
|
1273
|
+
step: parseInt(opts.step ?? "1", 10),
|
|
1274
|
+
type: opts.type,
|
|
1275
|
+
name: opts.name,
|
|
1276
|
+
data: opts.data
|
|
1277
|
+
},
|
|
1278
|
+
fs
|
|
1279
|
+
);
|
|
1280
|
+
console.log(JSON.stringify(result));
|
|
1281
|
+
}
|
|
1282
|
+
)
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// src/commands/complete-step.ts
|
|
1287
|
+
import "commander";
|
|
1288
|
+
|
|
1289
|
+
// src/handlers/complete-step.ts
|
|
1290
|
+
var HARSH_JUDGE_DIR9 = ".harshJudge";
|
|
1291
|
+
var SCENARIOS_DIR8 = "scenarios";
|
|
1292
|
+
var RUNS_DIR4 = "runs";
|
|
1293
|
+
var RESULT_FILE3 = "result.json";
|
|
1294
|
+
var META_FILE5 = "meta.yaml";
|
|
1295
|
+
async function findRunAndScenario(fs, runId) {
|
|
1296
|
+
const scenariosPath = `${HARSH_JUDGE_DIR9}/${SCENARIOS_DIR8}`;
|
|
1297
|
+
if (!await fs.exists(scenariosPath)) {
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
1300
|
+
const scenarios = await fs.listDirs(scenariosPath);
|
|
1301
|
+
for (const scenario of scenarios) {
|
|
1302
|
+
const scenarioPath = `${scenariosPath}/${scenario}`;
|
|
1303
|
+
const runPath = `${scenarioPath}/${RUNS_DIR4}/${runId}`;
|
|
1304
|
+
if (await fs.exists(runPath)) {
|
|
1305
|
+
return { runPath, scenarioSlug: scenario, scenarioPath };
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
return null;
|
|
1309
|
+
}
|
|
1310
|
+
async function getNextStepId(fs, scenarioPath, currentStepId) {
|
|
1311
|
+
const metaPath = `${scenarioPath}/${META_FILE5}`;
|
|
1312
|
+
if (!await fs.exists(metaPath)) {
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
const meta = await fs.readYaml(metaPath);
|
|
1316
|
+
if (!meta.steps || meta.steps.length === 0) {
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
const currentIdx = meta.steps.findIndex((s) => s.id === currentStepId);
|
|
1320
|
+
if (currentIdx === -1 || currentIdx >= meta.steps.length - 1) {
|
|
1321
|
+
return null;
|
|
1322
|
+
}
|
|
1323
|
+
return meta.steps[currentIdx + 1].id;
|
|
1324
|
+
}
|
|
1325
|
+
async function listStepEvidence(fs, runPath, stepId) {
|
|
1326
|
+
const evidencePath = `${runPath}/step-${stepId}/evidence`;
|
|
1327
|
+
if (!await fs.exists(evidencePath)) {
|
|
1328
|
+
return [];
|
|
1329
|
+
}
|
|
1330
|
+
const files = await fs.listFiles(evidencePath);
|
|
1331
|
+
return files.filter((f) => !f.endsWith(".meta.json"));
|
|
1332
|
+
}
|
|
1333
|
+
async function handleCompleteStep(params, fs = new FileSystemService()) {
|
|
1334
|
+
const validated = CompleteStepParamsSchema.parse(params);
|
|
1335
|
+
if (!await fs.exists(HARSH_JUDGE_DIR9)) {
|
|
1336
|
+
throw new Error("Project not initialized. Run initProject first.");
|
|
1337
|
+
}
|
|
1338
|
+
const found = await findRunAndScenario(fs, validated.runId);
|
|
1339
|
+
if (!found) {
|
|
1340
|
+
throw new Error(`Run "${validated.runId}" does not exist.`);
|
|
1341
|
+
}
|
|
1342
|
+
const { runPath, scenarioSlug, scenarioPath } = found;
|
|
1343
|
+
const resultPath = `${runPath}/${RESULT_FILE3}`;
|
|
1344
|
+
if (await fs.exists(resultPath)) {
|
|
1345
|
+
const existingResult = await fs.readJson(resultPath);
|
|
1346
|
+
if (existingResult.status !== "running") {
|
|
1347
|
+
throw new Error(`Run "${validated.runId}" is already completed. Cannot add step results.`);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
let inProgress;
|
|
1351
|
+
const runJsonPath = `${runPath}/run.json`;
|
|
1352
|
+
if (await fs.exists(resultPath)) {
|
|
1353
|
+
inProgress = await fs.readJson(resultPath);
|
|
1354
|
+
} else if (await fs.exists(runJsonPath)) {
|
|
1355
|
+
const runData = await fs.readJson(runJsonPath);
|
|
1356
|
+
inProgress = {
|
|
1357
|
+
runId: validated.runId,
|
|
1358
|
+
scenarioSlug,
|
|
1359
|
+
status: "running",
|
|
1360
|
+
startedAt: runData.startedAt,
|
|
1361
|
+
steps: []
|
|
1362
|
+
};
|
|
1363
|
+
} else {
|
|
1364
|
+
inProgress = {
|
|
1365
|
+
runId: validated.runId,
|
|
1366
|
+
scenarioSlug,
|
|
1367
|
+
status: "running",
|
|
1368
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1369
|
+
steps: []
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
const evidenceFiles = await listStepEvidence(fs, runPath, validated.stepId);
|
|
1373
|
+
const stepResult = {
|
|
1374
|
+
id: validated.stepId,
|
|
1375
|
+
status: validated.status,
|
|
1376
|
+
duration: validated.duration,
|
|
1377
|
+
error: validated.error ?? null,
|
|
1378
|
+
evidenceFiles,
|
|
1379
|
+
summary: validated.summary ?? null
|
|
1380
|
+
};
|
|
1381
|
+
const existingIdx = inProgress.steps.findIndex((s) => s.id === validated.stepId);
|
|
1382
|
+
if (existingIdx >= 0) {
|
|
1383
|
+
inProgress.steps[existingIdx] = stepResult;
|
|
1384
|
+
} else {
|
|
1385
|
+
inProgress.steps.push(stepResult);
|
|
1386
|
+
inProgress.steps.sort((a, b) => a.id.localeCompare(b.id));
|
|
1387
|
+
}
|
|
1388
|
+
await fs.writeJson(resultPath, inProgress);
|
|
1389
|
+
let nextStepId = null;
|
|
1390
|
+
if (validated.status === "pass" || validated.status === "skipped") {
|
|
1391
|
+
nextStepId = await getNextStepId(fs, scenarioPath, validated.stepId);
|
|
1392
|
+
}
|
|
1393
|
+
return {
|
|
1394
|
+
success: true,
|
|
1395
|
+
runId: validated.runId,
|
|
1396
|
+
stepId: validated.stepId,
|
|
1397
|
+
status: validated.status,
|
|
1398
|
+
nextStepId
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// src/commands/complete-step.ts
|
|
1403
|
+
function register7(program2) {
|
|
1404
|
+
program2.command("complete-step <runId>").description("Complete a single step in a test run").option("--step <id>", "Step ID (zero-padded, e.g. 01)").option("--status <status>", "Step status (pass, fail, skipped)").requiredOption("--duration <ms>", "Step duration in milliseconds").option("--error <msg>", "Error message if step failed").option("--summary <text>", "Step summary").action(
|
|
1405
|
+
withErrorHandling(
|
|
1406
|
+
async (runId, opts, cmd) => {
|
|
1407
|
+
const cwd = cmd.parent?.opts()["cwd"] ?? process.cwd();
|
|
1408
|
+
const fs = new FileSystemService(cwd);
|
|
1409
|
+
const result = await handleCompleteStep(
|
|
1410
|
+
{
|
|
1411
|
+
runId,
|
|
1412
|
+
stepId: opts.step,
|
|
1413
|
+
status: opts.status,
|
|
1414
|
+
duration: parseInt(opts.duration, 10),
|
|
1415
|
+
error: opts.error,
|
|
1416
|
+
summary: opts.summary
|
|
1417
|
+
},
|
|
1418
|
+
fs
|
|
1419
|
+
);
|
|
1420
|
+
console.log(JSON.stringify(result));
|
|
1421
|
+
}
|
|
1422
|
+
)
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// src/commands/complete-run.ts
|
|
1427
|
+
import "commander";
|
|
1428
|
+
|
|
1429
|
+
// src/handlers/complete-run.ts
|
|
1430
|
+
var HARSH_JUDGE_DIR10 = ".harshJudge";
|
|
1431
|
+
var SCENARIOS_DIR9 = "scenarios";
|
|
1432
|
+
var RUNS_DIR5 = "runs";
|
|
1433
|
+
var RESULT_FILE4 = "result.json";
|
|
1434
|
+
var META_FILE6 = "meta.yaml";
|
|
1435
|
+
async function findRunAndScenario2(fs, runId) {
|
|
1436
|
+
const scenariosPath = `${HARSH_JUDGE_DIR10}/${SCENARIOS_DIR9}`;
|
|
1437
|
+
if (!await fs.exists(scenariosPath)) {
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
const scenarios = await fs.listDirs(scenariosPath);
|
|
1441
|
+
for (const scenario of scenarios) {
|
|
1442
|
+
const scenarioPath = `${scenariosPath}/${scenario}`;
|
|
1443
|
+
const runPath = `${scenarioPath}/${RUNS_DIR5}/${runId}`;
|
|
1444
|
+
if (await fs.exists(runPath)) {
|
|
1445
|
+
return { runPath, scenarioPath };
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return null;
|
|
1449
|
+
}
|
|
1450
|
+
async function collectStepEvidence(fs, runPath) {
|
|
1451
|
+
const results = [];
|
|
1452
|
+
if (!await fs.exists(runPath)) {
|
|
1453
|
+
return results;
|
|
1454
|
+
}
|
|
1455
|
+
const dirs = await fs.listDirs(runPath);
|
|
1456
|
+
const stepDirs = dirs.filter((d) => /^step-\d{2}$/.test(d)).sort();
|
|
1457
|
+
for (const stepDir of stepDirs) {
|
|
1458
|
+
const stepId = stepDir.replace("step-", "");
|
|
1459
|
+
const evidencePath = `${runPath}/${stepDir}/evidence`;
|
|
1460
|
+
if (await fs.exists(evidencePath)) {
|
|
1461
|
+
const files = await fs.listFiles(evidencePath);
|
|
1462
|
+
const evidenceFiles = files.filter((f) => !f.endsWith(".meta.json"));
|
|
1463
|
+
results.push({ stepId, evidenceFiles });
|
|
1464
|
+
} else {
|
|
1465
|
+
results.push({ stepId, evidenceFiles: [] });
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return results;
|
|
1469
|
+
}
|
|
1470
|
+
function extractScenarioSlug(runPath) {
|
|
1471
|
+
const parts = runPath.split("/");
|
|
1472
|
+
const scenariosIdx = parts.indexOf("scenarios");
|
|
1473
|
+
if (scenariosIdx >= 0 && parts.length > scenariosIdx + 1) {
|
|
1474
|
+
return parts[scenariosIdx + 1];
|
|
1475
|
+
}
|
|
1476
|
+
return "unknown";
|
|
1477
|
+
}
|
|
1478
|
+
async function handleCompleteRun(params, fs = new FileSystemService()) {
|
|
1479
|
+
const validated = CompleteRunParamsSchema.parse(params);
|
|
1480
|
+
if (!await fs.exists(HARSH_JUDGE_DIR10)) {
|
|
1481
|
+
throw new Error("Project not initialized. Run initProject first.");
|
|
1482
|
+
}
|
|
1483
|
+
const found = await findRunAndScenario2(fs, validated.runId);
|
|
1484
|
+
if (!found) {
|
|
1485
|
+
throw new Error(`Run "${validated.runId}" does not exist.`);
|
|
1486
|
+
}
|
|
1487
|
+
const { runPath, scenarioPath } = found;
|
|
1488
|
+
const resultPath = `${runPath}/${RESULT_FILE4}`;
|
|
1489
|
+
let existingResult = null;
|
|
1490
|
+
if (await fs.exists(resultPath)) {
|
|
1491
|
+
existingResult = await fs.readJson(resultPath);
|
|
1492
|
+
if (existingResult.status !== "running") {
|
|
1493
|
+
throw new Error(`Run "${validated.runId}" is already completed.`);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
let steps;
|
|
1497
|
+
if (validated.steps && validated.steps.length > 0) {
|
|
1498
|
+
steps = validated.steps;
|
|
1499
|
+
} else if (existingResult?.steps && Array.isArray(existingResult.steps) && existingResult.steps.length > 0) {
|
|
1500
|
+
steps = existingResult.steps;
|
|
1501
|
+
} else {
|
|
1502
|
+
const stepEvidence = await collectStepEvidence(fs, runPath);
|
|
1503
|
+
steps = stepEvidence.map((se) => ({
|
|
1504
|
+
id: se.stepId,
|
|
1505
|
+
status: validated.failedStep === se.stepId ? "fail" : "pass",
|
|
1506
|
+
duration: 0,
|
|
1507
|
+
// Unknown for v1 compat
|
|
1508
|
+
error: validated.failedStep === se.stepId ? validated.errorMessage ?? null : null,
|
|
1509
|
+
evidenceFiles: se.evidenceFiles
|
|
1510
|
+
}));
|
|
1511
|
+
}
|
|
1512
|
+
let startedAt;
|
|
1513
|
+
if (existingResult && "startedAt" in existingResult) {
|
|
1514
|
+
startedAt = existingResult.startedAt;
|
|
1515
|
+
}
|
|
1516
|
+
if (!startedAt) {
|
|
1517
|
+
const runJsonPath = `${runPath}/run.json`;
|
|
1518
|
+
if (await fs.exists(runJsonPath)) {
|
|
1519
|
+
const runData = await fs.readJson(runJsonPath);
|
|
1520
|
+
startedAt = runData.startedAt;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
const scenarioSlug = extractScenarioSlug(runPath);
|
|
1524
|
+
const result = {
|
|
1525
|
+
runId: validated.runId,
|
|
1526
|
+
scenarioSlug,
|
|
1527
|
+
status: validated.status,
|
|
1528
|
+
startedAt: startedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1529
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1530
|
+
duration: validated.duration,
|
|
1531
|
+
steps,
|
|
1532
|
+
failedStep: validated.failedStep ?? null,
|
|
1533
|
+
errorMessage: validated.errorMessage ?? null
|
|
1534
|
+
};
|
|
1535
|
+
await fs.writeJson(resultPath, result);
|
|
1536
|
+
const metaPath = `${scenarioPath}/${META_FILE6}`;
|
|
1537
|
+
let existingMeta = {};
|
|
1538
|
+
if (await fs.exists(metaPath)) {
|
|
1539
|
+
existingMeta = await fs.readYaml(metaPath);
|
|
1540
|
+
}
|
|
1541
|
+
const currentStats = {
|
|
1542
|
+
totalRuns: existingMeta.totalRuns ?? 0,
|
|
1543
|
+
passCount: existingMeta.passCount ?? 0,
|
|
1544
|
+
failCount: existingMeta.failCount ?? 0,
|
|
1545
|
+
lastRun: existingMeta.lastRun ?? null,
|
|
1546
|
+
lastResult: existingMeta.lastResult ?? null,
|
|
1547
|
+
avgDuration: existingMeta.avgDuration ?? 0
|
|
1548
|
+
};
|
|
1549
|
+
const newTotalRuns = currentStats.totalRuns + 1;
|
|
1550
|
+
const newPassCount = currentStats.passCount + (validated.status === "pass" ? 1 : 0);
|
|
1551
|
+
const newFailCount = currentStats.failCount + (validated.status === "fail" ? 1 : 0);
|
|
1552
|
+
const totalDuration = currentStats.avgDuration * currentStats.totalRuns + validated.duration;
|
|
1553
|
+
const newAvgDuration = Math.round(totalDuration / newTotalRuns);
|
|
1554
|
+
const updatedMeta = {
|
|
1555
|
+
...existingMeta,
|
|
1556
|
+
totalRuns: newTotalRuns,
|
|
1557
|
+
passCount: newPassCount,
|
|
1558
|
+
failCount: newFailCount,
|
|
1559
|
+
lastRun: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1560
|
+
lastResult: validated.status,
|
|
1561
|
+
avgDuration: newAvgDuration
|
|
1562
|
+
};
|
|
1563
|
+
await fs.writeYaml(metaPath, updatedMeta);
|
|
1564
|
+
return {
|
|
1565
|
+
success: true,
|
|
1566
|
+
resultPath,
|
|
1567
|
+
updatedMeta: {
|
|
1568
|
+
totalRuns: newTotalRuns,
|
|
1569
|
+
passCount: newPassCount,
|
|
1570
|
+
failCount: newFailCount,
|
|
1571
|
+
avgDuration: newAvgDuration
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/commands/complete-run.ts
|
|
1577
|
+
function register8(program2) {
|
|
1578
|
+
program2.command("complete-run <runId>").description("Complete a test run and update scenario statistics").option("--status <status>", "Run status (pass, fail)").requiredOption("--duration <ms>", "Run duration in milliseconds").option("--failed-step <id>", "ID of the failed step").option("--error <msg>", "Error message").option("--steps <json>", "Steps as a JSON string").action(
|
|
1579
|
+
withErrorHandling(
|
|
1580
|
+
async (runId, opts, cmd) => {
|
|
1581
|
+
const cwd = cmd.parent?.opts()["cwd"] ?? process.cwd();
|
|
1582
|
+
const fs = new FileSystemService(cwd);
|
|
1583
|
+
const result = await handleCompleteRun(
|
|
1584
|
+
{
|
|
1585
|
+
runId,
|
|
1586
|
+
status: opts.status,
|
|
1587
|
+
duration: parseInt(opts.duration, 10),
|
|
1588
|
+
failedStep: opts.failedStep,
|
|
1589
|
+
errorMessage: opts.error,
|
|
1590
|
+
steps: opts.steps ? JSON.parse(opts.steps) : void 0
|
|
1591
|
+
},
|
|
1592
|
+
fs
|
|
1593
|
+
);
|
|
1594
|
+
console.log(JSON.stringify(result));
|
|
1595
|
+
}
|
|
1596
|
+
)
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// src/commands/dashboard.ts
|
|
1601
|
+
import "commander";
|
|
1602
|
+
|
|
1603
|
+
// src/handlers/open-dashboard.ts
|
|
1604
|
+
import { exec } from "child_process";
|
|
1605
|
+
function openBrowser(url) {
|
|
1606
|
+
const command = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
1607
|
+
exec(command, (err) => {
|
|
1608
|
+
if (err) {
|
|
1609
|
+
console.error(`[HarshJudge] Failed to open browser: ${err.message}`);
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
async function handleOpenDashboard(params, fs = new FileSystemService()) {
|
|
1614
|
+
const validated = OpenDashboardParamsSchema.parse(params);
|
|
1615
|
+
const manager = new DashboardManager(fs, validated.projectPath);
|
|
1616
|
+
const status = await manager.getStatus();
|
|
1617
|
+
if (status.running && status.url && status.port && status.pid) {
|
|
1618
|
+
if (validated.openBrowser) {
|
|
1619
|
+
openBrowser(status.url);
|
|
1620
|
+
}
|
|
1621
|
+
return {
|
|
1622
|
+
success: true,
|
|
1623
|
+
url: status.url,
|
|
1624
|
+
port: status.port,
|
|
1625
|
+
pid: status.pid,
|
|
1626
|
+
alreadyRunning: true,
|
|
1627
|
+
message: `Dashboard already running at ${status.url}`
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
try {
|
|
1631
|
+
const state = await manager.start(validated.port);
|
|
1632
|
+
if (validated.openBrowser) {
|
|
1633
|
+
openBrowser(state.url);
|
|
1634
|
+
}
|
|
1635
|
+
return {
|
|
1636
|
+
success: true,
|
|
1637
|
+
url: state.url,
|
|
1638
|
+
port: state.port,
|
|
1639
|
+
pid: state.pid,
|
|
1640
|
+
alreadyRunning: false,
|
|
1641
|
+
message: `Dashboard started at ${state.url}`
|
|
1642
|
+
};
|
|
1643
|
+
} catch (error) {
|
|
1644
|
+
throw new Error(`Failed to start dashboard: ${error instanceof Error ? error.message : String(error)}`);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// src/handlers/close-dashboard.ts
|
|
1649
|
+
async function handleCloseDashboard(params, fs = new FileSystemService()) {
|
|
1650
|
+
const validated = CloseDashboardParamsSchema.parse(params);
|
|
1651
|
+
const manager = new DashboardManager(fs, validated.projectPath);
|
|
1652
|
+
const status = await manager.getStatus();
|
|
1653
|
+
const wasRunning = status.running || status.stale === true;
|
|
1654
|
+
const result = await manager.stop();
|
|
1655
|
+
return {
|
|
1656
|
+
success: result.stopped || !wasRunning,
|
|
1657
|
+
wasRunning,
|
|
1658
|
+
message: result.message
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// src/handlers/get-dashboard-status.ts
|
|
1663
|
+
async function handleGetDashboardStatus(params, fs = new FileSystemService()) {
|
|
1664
|
+
const validated = GetDashboardStatusParamsSchema.parse(params);
|
|
1665
|
+
const manager = new DashboardManager(fs, validated.projectPath);
|
|
1666
|
+
const status = await manager.getStatus();
|
|
1667
|
+
let message;
|
|
1668
|
+
if (status.running) {
|
|
1669
|
+
message = `Dashboard running at ${status.url} (PID: ${status.pid})`;
|
|
1670
|
+
} else if (status.stale) {
|
|
1671
|
+
message = `Dashboard state is stale (process ${status.pid} is dead). Run openDashboard to start a new one.`;
|
|
1672
|
+
} else {
|
|
1673
|
+
message = "Dashboard is not running. Use openDashboard to start it.";
|
|
1674
|
+
}
|
|
1675
|
+
return {
|
|
1676
|
+
running: status.running,
|
|
1677
|
+
pid: status.pid,
|
|
1678
|
+
port: status.port,
|
|
1679
|
+
url: status.url,
|
|
1680
|
+
startedAt: status.startedAt,
|
|
1681
|
+
stale: status.stale,
|
|
1682
|
+
message
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// src/commands/dashboard.ts
|
|
1687
|
+
function register9(program2) {
|
|
1688
|
+
const dashboard = program2.command("dashboard").description("Manage the HarshJudge dashboard server");
|
|
1689
|
+
dashboard.command("open").description("Start or reconnect to the dashboard server").option("--port <n>", "Port to run the dashboard on").option("--no-browser", "Do not open browser automatically").action(
|
|
1690
|
+
withErrorHandling(
|
|
1691
|
+
async (opts, cmd) => {
|
|
1692
|
+
const cwd = cmd.parent?.parent?.opts()["cwd"] ?? process.cwd();
|
|
1693
|
+
const fs = new FileSystemService(cwd);
|
|
1694
|
+
const result = await handleOpenDashboard(
|
|
1695
|
+
{
|
|
1696
|
+
port: opts.port ? parseInt(opts.port, 10) : void 0,
|
|
1697
|
+
openBrowser: opts.browser !== false,
|
|
1698
|
+
projectPath: cwd
|
|
1699
|
+
},
|
|
1700
|
+
fs
|
|
1701
|
+
);
|
|
1702
|
+
console.log(JSON.stringify(result));
|
|
1703
|
+
}
|
|
1704
|
+
)
|
|
1705
|
+
);
|
|
1706
|
+
dashboard.command("close").description("Stop the dashboard server").action(
|
|
1707
|
+
withErrorHandling(async (_opts, cmd) => {
|
|
1708
|
+
const cwd = cmd.parent?.parent?.opts()["cwd"] ?? process.cwd();
|
|
1709
|
+
const fs = new FileSystemService(cwd);
|
|
1710
|
+
const result = await handleCloseDashboard({ projectPath: cwd }, fs);
|
|
1711
|
+
console.log(JSON.stringify(result));
|
|
1712
|
+
})
|
|
1713
|
+
);
|
|
1714
|
+
dashboard.command("status").description("Get the current dashboard status").action(
|
|
1715
|
+
withErrorHandling(async (_opts, cmd) => {
|
|
1716
|
+
const cwd = cmd.parent?.parent?.opts()["cwd"] ?? process.cwd();
|
|
1717
|
+
const fs = new FileSystemService(cwd);
|
|
1718
|
+
const result = await handleGetDashboardStatus({ projectPath: cwd }, fs);
|
|
1719
|
+
console.log(JSON.stringify(result));
|
|
1720
|
+
})
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// src/commands/discover.ts
|
|
1725
|
+
import "commander";
|
|
1726
|
+
import { readdir as readdir2, readFile as readFile2, stat } from "fs/promises";
|
|
1727
|
+
import { join as join3, relative } from "path";
|
|
1728
|
+
import yaml2 from "js-yaml";
|
|
1729
|
+
var META_YAML_FIELDS = [
|
|
1730
|
+
"title",
|
|
1731
|
+
"slug",
|
|
1732
|
+
"starred",
|
|
1733
|
+
"tags",
|
|
1734
|
+
"totalRuns",
|
|
1735
|
+
"passCount",
|
|
1736
|
+
"failCount",
|
|
1737
|
+
"lastRun",
|
|
1738
|
+
"lastResult",
|
|
1739
|
+
"avgDuration"
|
|
1740
|
+
];
|
|
1741
|
+
var CONFIG_YAML_FIELDS = ["projectName", "baseUrl", "version", "createdAt"];
|
|
1742
|
+
var RESULT_JSON_FIELDS = ["status", "duration", "runId"];
|
|
1743
|
+
function pickFields(obj, fields) {
|
|
1744
|
+
const result = {};
|
|
1745
|
+
for (const key of fields) {
|
|
1746
|
+
if (key in obj) result[key] = obj[key];
|
|
1747
|
+
}
|
|
1748
|
+
return result;
|
|
1749
|
+
}
|
|
1750
|
+
async function walkDir(dirPath, basePath, _fileName) {
|
|
1751
|
+
const entries = await readdir2(dirPath, { withFileTypes: true });
|
|
1752
|
+
const node = {};
|
|
1753
|
+
for (const entry of entries) {
|
|
1754
|
+
const fullPath = join3(dirPath, entry.name);
|
|
1755
|
+
if (entry.isDirectory()) {
|
|
1756
|
+
node[`${entry.name}/`] = await walkDir(fullPath, basePath, entry.name);
|
|
1757
|
+
} else {
|
|
1758
|
+
if (entry.name === "meta.yaml") {
|
|
1759
|
+
try {
|
|
1760
|
+
const raw = yaml2.load(await readFile2(fullPath, "utf8"));
|
|
1761
|
+
node[entry.name] = pickFields(raw, META_YAML_FIELDS);
|
|
1762
|
+
} catch {
|
|
1763
|
+
node[entry.name] = {};
|
|
1764
|
+
}
|
|
1765
|
+
} else if (entry.name === "config.yaml") {
|
|
1766
|
+
try {
|
|
1767
|
+
const raw = yaml2.load(await readFile2(fullPath, "utf8"));
|
|
1768
|
+
node[entry.name] = pickFields(raw, CONFIG_YAML_FIELDS);
|
|
1769
|
+
} catch {
|
|
1770
|
+
node[entry.name] = {};
|
|
1771
|
+
}
|
|
1772
|
+
} else if (entry.name === "result.json") {
|
|
1773
|
+
try {
|
|
1774
|
+
const raw = JSON.parse(await readFile2(fullPath, "utf8"));
|
|
1775
|
+
node[entry.name] = pickFields(raw, RESULT_JSON_FIELDS);
|
|
1776
|
+
} catch {
|
|
1777
|
+
node[entry.name] = {};
|
|
1778
|
+
}
|
|
1779
|
+
} else {
|
|
1780
|
+
node[entry.name] = true;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
return node;
|
|
1785
|
+
}
|
|
1786
|
+
async function buildTree(basePath, subPath) {
|
|
1787
|
+
const rootPath = subPath ? join3(basePath, ".harshJudge", subPath) : join3(basePath, ".harshJudge");
|
|
1788
|
+
const dirStat = await stat(rootPath);
|
|
1789
|
+
if (!dirStat.isDirectory()) {
|
|
1790
|
+
throw new Error(`Path is not a directory: ${rootPath}`);
|
|
1791
|
+
}
|
|
1792
|
+
const tree = await walkDir(rootPath, basePath, "");
|
|
1793
|
+
const rootLabel = relative(basePath, rootPath) + "/";
|
|
1794
|
+
return { root: rootLabel, tree };
|
|
1795
|
+
}
|
|
1796
|
+
var SEARCHABLE_EXTENSIONS = /* @__PURE__ */ new Set([".yaml", ".yml", ".json", ".md"]);
|
|
1797
|
+
async function* walkForSearch(dirPath) {
|
|
1798
|
+
const entries = await readdir2(dirPath, { withFileTypes: true });
|
|
1799
|
+
for (const entry of entries) {
|
|
1800
|
+
const fullPath = join3(dirPath, entry.name);
|
|
1801
|
+
if (entry.isDirectory()) {
|
|
1802
|
+
yield* walkForSearch(fullPath);
|
|
1803
|
+
} else {
|
|
1804
|
+
const ext = entry.name.slice(entry.name.lastIndexOf("."));
|
|
1805
|
+
if (SEARCHABLE_EXTENSIONS.has(ext)) {
|
|
1806
|
+
yield fullPath;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
async function searchFiles(basePath, pattern, subPath) {
|
|
1812
|
+
const searchRoot = subPath ? join3(basePath, ".harshJudge", subPath) : join3(basePath, ".harshJudge");
|
|
1813
|
+
const lowerPattern = pattern.toLowerCase();
|
|
1814
|
+
const matches = [];
|
|
1815
|
+
for await (const filePath of walkForSearch(searchRoot)) {
|
|
1816
|
+
let content;
|
|
1817
|
+
try {
|
|
1818
|
+
content = await readFile2(filePath, "utf8");
|
|
1819
|
+
} catch {
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
const lines = content.split("\n");
|
|
1823
|
+
for (const line of lines) {
|
|
1824
|
+
if (line.toLowerCase().includes(lowerPattern)) {
|
|
1825
|
+
matches.push({ file: filePath, match: line.trim() });
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return { matches };
|
|
1830
|
+
}
|
|
1831
|
+
function register10(program2) {
|
|
1832
|
+
const discover = program2.command("discover").description("Explore .harshJudge/ structure");
|
|
1833
|
+
discover.command("tree [path]").description("Show folder structure with metadata").action(
|
|
1834
|
+
withErrorHandling(async (subPath, cmd) => {
|
|
1835
|
+
const cwd = cmd.parent?.parent?.opts()["cwd"] ?? process.cwd();
|
|
1836
|
+
const result = await buildTree(cwd, subPath);
|
|
1837
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1838
|
+
})
|
|
1839
|
+
);
|
|
1840
|
+
discover.command("search <pattern>").description("Search file content in .harshJudge/").option("--path <folder>", "Restrict search to subfolder").action(
|
|
1841
|
+
withErrorHandling(
|
|
1842
|
+
async (pattern, opts, cmd) => {
|
|
1843
|
+
const cwd = cmd.parent?.parent?.opts()["cwd"] ?? process.cwd();
|
|
1844
|
+
const result = await searchFiles(cwd, pattern, opts.path);
|
|
1845
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1846
|
+
}
|
|
1847
|
+
)
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// src/cli.ts
|
|
1852
|
+
var __dirname = dirname3(fileURLToPath2(import.meta.url));
|
|
1853
|
+
var pkg = JSON.parse(
|
|
1854
|
+
readFileSync2(join4(__dirname, "..", "package.json"), "utf8")
|
|
1855
|
+
);
|
|
1856
|
+
var program = new Command11();
|
|
1857
|
+
program.name("harshjudge").description("AI-native E2E testing orchestration CLI").version(pkg.version).option("--cwd <path>", "Working directory override");
|
|
1858
|
+
register(program);
|
|
1859
|
+
register2(program);
|
|
1860
|
+
register3(program);
|
|
1861
|
+
register4(program);
|
|
1862
|
+
register5(program);
|
|
1863
|
+
register6(program);
|
|
1864
|
+
register7(program);
|
|
1865
|
+
register8(program);
|
|
1866
|
+
register9(program);
|
|
1867
|
+
register10(program);
|
|
1868
|
+
program.parse();
|
|
1869
|
+
//# sourceMappingURL=cli.js.map
|