zerocheck 0.0.1
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/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/index.js +777 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerocheck
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Zerocheck CLI
|
|
2
|
+
|
|
3
|
+
AI-powered end-to-end testing from your terminal. Point it at a staging URL, get a recorded browser replay back.
|
|
4
|
+
|
|
5
|
+
Tests run in Zerocheck cloud — no local Playwright, no API keys to manage. Your source code, secrets, and git history stay on your machine.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Requires Node 20+.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx zerocheck@latest login
|
|
13
|
+
npx zerocheck@latest run --url https://staging.yourapp.com
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or install globally:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install -g zerocheck
|
|
20
|
+
zerocheck login
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
| Command | What it does |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `zerocheck login` | Device-code auth. Opens a browser for approval; token stored at `~/.zerocheck/config.json` (mode 0600). |
|
|
28
|
+
| `zerocheck logout` | Revoke the current device's token. |
|
|
29
|
+
| `zerocheck whoami` | Show auth state + linked project. |
|
|
30
|
+
| `zerocheck run [file] --url <url>` | Run tests. Auto-generates starter tests on an empty project unless `--no-bootstrap`. |
|
|
31
|
+
| `zerocheck generate --url <url>` | AI-generate tests against a URL. `--save` to write them to `./zerocheck/tests/`. |
|
|
32
|
+
| `zerocheck discover --url <url>` | Crawl the app and draft an initial suite. |
|
|
33
|
+
| `zerocheck validate [path]` | Lint `.zc` files server-side. |
|
|
34
|
+
| `zerocheck doctor` | Config, token, backend reachability, version compat. |
|
|
35
|
+
|
|
36
|
+
## MCP server (coming soon)
|
|
37
|
+
|
|
38
|
+
An MCP server for Claude Code, Cursor, Codex, and Amp is on the roadmap — see [tryzerocheck.com/cli](https://tryzerocheck.com/cli).
|
|
39
|
+
|
|
40
|
+
## Privacy boundary
|
|
41
|
+
|
|
42
|
+
**Leaves your machine:** target URLs, rendered screenshots, accessibility tree, `.zc` file contents.
|
|
43
|
+
|
|
44
|
+
**Stays local:** source code, environment variables, secrets, git history beyond explicit diffs.
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
Override the API endpoint (for self-hosted Zerocheck):
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
ZEROCHECK_API=https://api.example.com zerocheck login
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT — see [LICENSE](./LICENSE).
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/login.ts
|
|
7
|
+
import { hostname } from "os";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
|
|
10
|
+
// src/api-client.ts
|
|
11
|
+
import { request } from "undici";
|
|
12
|
+
|
|
13
|
+
// src/config.ts
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "fs";
|
|
17
|
+
var CONFIG_DIR = join(homedir(), ".zerocheck");
|
|
18
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
19
|
+
var DEFAULT_API_BASE = "https://api.tryzerocheck.com";
|
|
20
|
+
function apiBase() {
|
|
21
|
+
const raw = process.env.ZEROCHECK_API ?? DEFAULT_API_BASE;
|
|
22
|
+
return raw.replace(/\/+$/, "");
|
|
23
|
+
}
|
|
24
|
+
function loadConfig() {
|
|
25
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function saveConfig(cfg) {
|
|
33
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
34
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 384 });
|
|
35
|
+
}
|
|
36
|
+
function clearConfig() {
|
|
37
|
+
if (existsSync(CONFIG_FILE)) rmSync(CONFIG_FILE);
|
|
38
|
+
}
|
|
39
|
+
function configPath() {
|
|
40
|
+
return CONFIG_FILE;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/api-client.ts
|
|
44
|
+
function isApiError(e) {
|
|
45
|
+
return !!e && typeof e === "object" && "status" in e && "error" in e;
|
|
46
|
+
}
|
|
47
|
+
async function apiRequest(path, init = {}) {
|
|
48
|
+
const url = apiBase() + path;
|
|
49
|
+
const headers = { "content-type": "application/json" };
|
|
50
|
+
if (init.authed !== false) {
|
|
51
|
+
const cfg = loadConfig();
|
|
52
|
+
if (cfg.access_token) {
|
|
53
|
+
headers["authorization"] = `Bearer ${cfg.access_token}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const { statusCode, body } = await request(url, {
|
|
57
|
+
method: init.method ?? "POST",
|
|
58
|
+
headers,
|
|
59
|
+
body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
|
|
60
|
+
});
|
|
61
|
+
const text = await body.text();
|
|
62
|
+
let parsed = null;
|
|
63
|
+
if (text) {
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(text);
|
|
66
|
+
} catch {
|
|
67
|
+
parsed = text;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (statusCode >= 400) {
|
|
71
|
+
const payload = parsed && typeof parsed === "object" ? parsed : {};
|
|
72
|
+
const err = {
|
|
73
|
+
status: statusCode,
|
|
74
|
+
error: typeof payload["error"] === "string" ? payload["error"] : "http_error",
|
|
75
|
+
message: typeof payload["message"] === "string" ? payload["message"] : void 0
|
|
76
|
+
};
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/commands/login.ts
|
|
83
|
+
function sleep(ms) {
|
|
84
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
85
|
+
}
|
|
86
|
+
async function loginCommand() {
|
|
87
|
+
const existing = loadConfig();
|
|
88
|
+
if (existing.access_token) {
|
|
89
|
+
console.log(chalk.yellow("Already logged in.") + " Run `zerocheck logout` first to re-authenticate.");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const deviceName = hostname();
|
|
93
|
+
console.log(chalk.dim(`Connecting to ${apiBase()}...`));
|
|
94
|
+
let device;
|
|
95
|
+
try {
|
|
96
|
+
device = await apiRequest("/v1/cli/auth/device", {
|
|
97
|
+
body: { device_name: deviceName, client_version: "0.1.0" },
|
|
98
|
+
authed: false
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (isApiError(err)) {
|
|
102
|
+
console.error(chalk.red(`Login failed (${err.status}): ${err.message ?? err.error}`));
|
|
103
|
+
} else {
|
|
104
|
+
console.error(chalk.red("Could not reach Zerocheck:"), err);
|
|
105
|
+
}
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
console.log();
|
|
109
|
+
console.log(chalk.bold("Open this URL in your browser:"));
|
|
110
|
+
console.log(" " + chalk.cyan(device.verification_uri_complete));
|
|
111
|
+
console.log();
|
|
112
|
+
console.log(chalk.bold("Confirm this code:") + " " + chalk.green.bold(device.user_code));
|
|
113
|
+
console.log();
|
|
114
|
+
console.log(chalk.dim(`Waiting for approval (expires in ${Math.floor(device.expires_in / 60)} min)...`));
|
|
115
|
+
const deadline = Date.now() + device.expires_in * 1e3;
|
|
116
|
+
const intervalMs = device.interval * 1e3;
|
|
117
|
+
while (Date.now() < deadline) {
|
|
118
|
+
await sleep(intervalMs);
|
|
119
|
+
try {
|
|
120
|
+
const poll = await apiRequest("/v1/cli/auth/poll", {
|
|
121
|
+
body: { device_code: device.device_code },
|
|
122
|
+
authed: false
|
|
123
|
+
});
|
|
124
|
+
if (poll.status === "approved") {
|
|
125
|
+
saveConfig({
|
|
126
|
+
access_token: poll.access_token,
|
|
127
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
128
|
+
device_name: deviceName
|
|
129
|
+
});
|
|
130
|
+
console.log();
|
|
131
|
+
console.log(chalk.green("\u2713 Logged in as ") + chalk.bold(deviceName));
|
|
132
|
+
console.log();
|
|
133
|
+
console.log(chalk.dim("Privacy: target URLs, screenshots, and accessibility trees of tests you"));
|
|
134
|
+
console.log(chalk.dim("run are sent to Zerocheck backend for AI execution. Your source code and"));
|
|
135
|
+
console.log(chalk.dim("env vars stay on your machine."));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (isApiError(err)) {
|
|
140
|
+
if (err.status === 410 || err.status === 403) {
|
|
141
|
+
console.error();
|
|
142
|
+
console.error(chalk.red(err.message ?? err.error));
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
console.error();
|
|
149
|
+
console.error(chalk.red("Login timed out. Run `zerocheck login` to try again."));
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/commands/logout.ts
|
|
154
|
+
import chalk2 from "chalk";
|
|
155
|
+
async function logoutCommand() {
|
|
156
|
+
const cfg = loadConfig();
|
|
157
|
+
if (!cfg.access_token) {
|
|
158
|
+
console.log(chalk2.dim("Not logged in."));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
await apiRequest("/v1/cli/auth/revoke", { body: {} });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (isApiError(err) && err.status !== 401) {
|
|
165
|
+
console.warn(chalk2.yellow(`Server revoke returned ${err.status}. Clearing local config anyway.`));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
clearConfig();
|
|
169
|
+
console.log(chalk2.green("\u2713 Logged out."));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/commands/whoami.ts
|
|
173
|
+
import chalk3 from "chalk";
|
|
174
|
+
async function whoamiCommand() {
|
|
175
|
+
const cfg = loadConfig();
|
|
176
|
+
if (!cfg.access_token) {
|
|
177
|
+
console.log(chalk3.yellow("Not logged in.") + " Run `zerocheck login`.");
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const status = await apiRequest("/v1/cli/status", { method: "GET" });
|
|
182
|
+
console.log(chalk3.bold("Zerocheck"));
|
|
183
|
+
console.log(` API ${apiBase()}`);
|
|
184
|
+
console.log(` Token ${status.token?.prefix ?? "\u2014"}`);
|
|
185
|
+
console.log(` Device ${status.token?.device_name ?? "\u2014"}`);
|
|
186
|
+
if (status.project) {
|
|
187
|
+
console.log(` Project ${status.project.name}`);
|
|
188
|
+
console.log(` Staging URL ${status.project.staging_url ?? chalk3.dim("(not set)")}`);
|
|
189
|
+
console.log(
|
|
190
|
+
` GitHub ${status.project.github_linked ? status.project.github_repo ?? "(linked)" : chalk3.dim("not linked")}`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (isApiError(err)) {
|
|
195
|
+
if (err.status === 401) {
|
|
196
|
+
console.error(chalk3.red("Session expired.") + " Run `zerocheck login` again.");
|
|
197
|
+
} else {
|
|
198
|
+
console.error(chalk3.red(`Status check failed (${err.status}): ${err.message ?? err.error}`));
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
console.error(chalk3.red("Status check failed:"), err);
|
|
202
|
+
}
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/commands/validate.ts
|
|
208
|
+
import { readdirSync, readFileSync as readFileSync2, statSync } from "fs";
|
|
209
|
+
import { join as join2, resolve } from "path";
|
|
210
|
+
import chalk4 from "chalk";
|
|
211
|
+
function findZcFiles(target) {
|
|
212
|
+
const abs = resolve(target);
|
|
213
|
+
let stat;
|
|
214
|
+
try {
|
|
215
|
+
stat = statSync(abs);
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
if (stat.isFile()) return [abs];
|
|
220
|
+
if (!stat.isDirectory()) return [];
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const entry of readdirSync(abs, { withFileTypes: true })) {
|
|
223
|
+
const full = join2(abs, entry.name);
|
|
224
|
+
if (entry.isDirectory()) {
|
|
225
|
+
out.push(...findZcFiles(full));
|
|
226
|
+
} else if (entry.isFile() && entry.name.endsWith(".zc")) {
|
|
227
|
+
out.push(full);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
async function validateCommand(target) {
|
|
233
|
+
const dir = target ?? "./zerocheck/tests";
|
|
234
|
+
const files = findZcFiles(dir);
|
|
235
|
+
if (files.length === 0) {
|
|
236
|
+
console.log(chalk4.yellow(`No .zc files found at ${dir}.`));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
let anyFailed = false;
|
|
240
|
+
for (const path of files) {
|
|
241
|
+
let content;
|
|
242
|
+
try {
|
|
243
|
+
content = readFileSync2(path, "utf-8");
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error(chalk4.red(`${path}: could not read (${err instanceof Error ? err.message : err})`));
|
|
246
|
+
anyFailed = true;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const result = await apiRequest("/v1/cli/validate", {
|
|
251
|
+
body: { path, content }
|
|
252
|
+
});
|
|
253
|
+
const rel = path.replace(process.cwd() + "/", "");
|
|
254
|
+
if (result.valid) {
|
|
255
|
+
console.log(chalk4.green("\u2713") + ` ${rel} ` + chalk4.dim(`(${result.test_count} test${result.test_count === 1 ? "" : "s"})`));
|
|
256
|
+
} else {
|
|
257
|
+
anyFailed = true;
|
|
258
|
+
console.log(chalk4.red("\u2717") + ` ${rel}`);
|
|
259
|
+
for (const err of result.errors) {
|
|
260
|
+
console.log(chalk4.dim(` ${rel}:${err.line_number}`) + ` ${err.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} catch (err) {
|
|
264
|
+
anyFailed = true;
|
|
265
|
+
if (isApiError(err)) {
|
|
266
|
+
if (err.status === 401) {
|
|
267
|
+
console.error(chalk4.red("Not logged in.") + " Run `zerocheck login` first.");
|
|
268
|
+
process.exit(2);
|
|
269
|
+
}
|
|
270
|
+
console.error(chalk4.red(`${path}: validate failed (${err.status}): ${err.message ?? err.error}`));
|
|
271
|
+
} else {
|
|
272
|
+
console.error(chalk4.red(`${path}: ${err instanceof Error ? err.message : String(err)}`));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
process.exit(anyFailed ? 1 : 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/commands/generate.ts
|
|
280
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
|
|
281
|
+
import { join as join3 } from "path";
|
|
282
|
+
import chalk5 from "chalk";
|
|
283
|
+
function sanitize(name) {
|
|
284
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "test";
|
|
285
|
+
}
|
|
286
|
+
async function generateCommand(opts) {
|
|
287
|
+
if (!opts.url) {
|
|
288
|
+
console.error(chalk5.red("--url is required."));
|
|
289
|
+
process.exit(2);
|
|
290
|
+
}
|
|
291
|
+
const maxTests = opts.maxTests ? Math.max(1, Math.min(10, parseInt(opts.maxTests, 10) || 3)) : 3;
|
|
292
|
+
console.log(chalk5.dim(`Generating ${maxTests} test${maxTests === 1 ? "" : "s"} for ${opts.url}...`));
|
|
293
|
+
let result;
|
|
294
|
+
try {
|
|
295
|
+
result = await apiRequest("/v1/cli/generate", {
|
|
296
|
+
body: { url: opts.url, description: opts.description, max_tests: maxTests }
|
|
297
|
+
});
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (isApiError(err)) {
|
|
300
|
+
if (err.status === 401) {
|
|
301
|
+
console.error(chalk5.red("Not logged in.") + " Run `zerocheck login` first.");
|
|
302
|
+
process.exit(2);
|
|
303
|
+
}
|
|
304
|
+
if (err.status === 429) {
|
|
305
|
+
console.error(chalk5.red(err.message ?? "Rate limited."));
|
|
306
|
+
process.exit(3);
|
|
307
|
+
}
|
|
308
|
+
console.error(chalk5.red(`Generate failed (${err.status}): ${err.message ?? err.error}`));
|
|
309
|
+
} else {
|
|
310
|
+
console.error(chalk5.red("Generate failed:"), err);
|
|
311
|
+
}
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
console.log(chalk5.green(`
|
|
315
|
+
\u2713 Generated ${result.count} test${result.count === 1 ? "" : "s"}
|
|
316
|
+
`));
|
|
317
|
+
for (const test of result.tests) {
|
|
318
|
+
console.log(chalk5.bold(` ${test.name}`));
|
|
319
|
+
if (test.reasoning) console.log(chalk5.dim(` ${test.reasoning}`));
|
|
320
|
+
}
|
|
321
|
+
if (opts.save) {
|
|
322
|
+
const outDir = opts.output ?? "./zerocheck/tests";
|
|
323
|
+
mkdirSync2(outDir, { recursive: true });
|
|
324
|
+
const written = [];
|
|
325
|
+
for (const test of result.tests) {
|
|
326
|
+
const filename = `${sanitize(test.name)}.zc`;
|
|
327
|
+
const path = join3(outDir, filename);
|
|
328
|
+
if (existsSync2(path)) {
|
|
329
|
+
console.warn(chalk5.yellow(` skipped ${path} (already exists)`));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
writeFileSync2(path, test.yaml, "utf-8");
|
|
333
|
+
written.push(path);
|
|
334
|
+
}
|
|
335
|
+
if (written.length > 0) {
|
|
336
|
+
console.log(chalk5.green(`
|
|
337
|
+
\u2713 Wrote ${written.length} file${written.length === 1 ? "" : "s"}:`));
|
|
338
|
+
for (const p of written) console.log(chalk5.dim(` ${p}`));
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
console.log(chalk5.dim("\nRerun with --save to write these to ./zerocheck/tests/."));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/commands/run.ts
|
|
346
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, existsSync as existsSync3 } from "fs";
|
|
347
|
+
import { join as join4, resolve as resolve2 } from "path";
|
|
348
|
+
import chalk6 from "chalk";
|
|
349
|
+
|
|
350
|
+
// src/stream-client.ts
|
|
351
|
+
import WebSocket from "ws";
|
|
352
|
+
function openRunStream(runId, onEvent) {
|
|
353
|
+
const cfg = loadConfig();
|
|
354
|
+
const base = apiBase().replace(/^http/, "ws");
|
|
355
|
+
const url = `${base}/v1/cli/stream?run_id=${encodeURIComponent(runId)}`;
|
|
356
|
+
const headers = {};
|
|
357
|
+
if (cfg.access_token) headers["Authorization"] = `Bearer ${cfg.access_token}`;
|
|
358
|
+
let ws = null;
|
|
359
|
+
let closed = false;
|
|
360
|
+
try {
|
|
361
|
+
ws = new WebSocket(url, { headers });
|
|
362
|
+
ws.on("message", (raw) => {
|
|
363
|
+
try {
|
|
364
|
+
const event = JSON.parse(raw.toString());
|
|
365
|
+
onEvent(event);
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
ws.on("error", () => {
|
|
370
|
+
});
|
|
371
|
+
} catch {
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
close: () => {
|
|
375
|
+
if (closed) return;
|
|
376
|
+
closed = true;
|
|
377
|
+
try {
|
|
378
|
+
ws?.close();
|
|
379
|
+
} catch {
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/commands/run.ts
|
|
386
|
+
var TESTS_DIR = "./zerocheck/tests";
|
|
387
|
+
var POLL_INTERVAL_MS = 2e3;
|
|
388
|
+
var MAX_POLL_MS = 15 * 60 * 1e3;
|
|
389
|
+
function findZcFiles2(dir) {
|
|
390
|
+
try {
|
|
391
|
+
const stat = statSync2(dir);
|
|
392
|
+
if (!stat.isDirectory()) return [];
|
|
393
|
+
} catch {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
const out = [];
|
|
397
|
+
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
398
|
+
const full = join4(dir, entry.name);
|
|
399
|
+
if (entry.isDirectory()) out.push(...findZcFiles2(full));
|
|
400
|
+
else if (entry.isFile() && entry.name.endsWith(".zc")) out.push(full);
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
function sanitize2(name) {
|
|
405
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "test";
|
|
406
|
+
}
|
|
407
|
+
function sleep2(ms) {
|
|
408
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
409
|
+
}
|
|
410
|
+
async function bootstrapTests(url) {
|
|
411
|
+
console.log(chalk6.dim(`No tests at ${TESTS_DIR}. Generating starter tests for ${url}...`));
|
|
412
|
+
let result;
|
|
413
|
+
try {
|
|
414
|
+
result = await apiRequest("/v1/cli/generate", {
|
|
415
|
+
body: { url, max_tests: 3 }
|
|
416
|
+
});
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (isApiError(err) && err.status === 401) {
|
|
419
|
+
console.error(chalk6.red("Not logged in.") + " Run `zerocheck login` first.");
|
|
420
|
+
process.exit(2);
|
|
421
|
+
}
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
mkdirSync3(TESTS_DIR, { recursive: true });
|
|
425
|
+
const written = [];
|
|
426
|
+
for (const test of result.tests) {
|
|
427
|
+
const path = join4(TESTS_DIR, `${sanitize2(test.name)}.zc`);
|
|
428
|
+
if (existsSync3(path)) continue;
|
|
429
|
+
writeFileSync3(path, test.yaml, "utf-8");
|
|
430
|
+
written.push(path);
|
|
431
|
+
}
|
|
432
|
+
console.log(chalk6.green(`\u2713 Generated ${result.count} starter test${result.count === 1 ? "" : "s"}:`));
|
|
433
|
+
for (const p of written) console.log(chalk6.dim(` ${p}`));
|
|
434
|
+
console.log();
|
|
435
|
+
return written;
|
|
436
|
+
}
|
|
437
|
+
function renderFinalReport(detail) {
|
|
438
|
+
console.log();
|
|
439
|
+
const statusColor = detail.status === "passed" ? chalk6.green : detail.status === "failed" ? chalk6.red : chalk6.yellow;
|
|
440
|
+
console.log(statusColor(`\u25CF Run ${detail.status}`) + chalk6.dim(` (${(detail.totalDuration / 1e3).toFixed(1)}s)`));
|
|
441
|
+
console.log(chalk6.dim(` ${detail.totalPassed} passed ${detail.totalFailed} failed confidence ${detail.confidence}%`));
|
|
442
|
+
console.log();
|
|
443
|
+
for (const t of detail.testResults) {
|
|
444
|
+
const mark = t.status === "pass" ? chalk6.green("\u2713") : chalk6.red("\u2717");
|
|
445
|
+
console.log(`${mark} ${t.testName} ` + chalk6.dim(`(${(t.duration / 1e3).toFixed(1)}s)`));
|
|
446
|
+
if (t.status !== "pass" && t.steps) {
|
|
447
|
+
for (const s of t.steps) {
|
|
448
|
+
if (s.status === "fail") {
|
|
449
|
+
console.log(chalk6.dim(` ${s.keyword}`) + (s.argument ? ` ${s.argument}` : ""));
|
|
450
|
+
if (s.error) console.log(chalk6.red(` error: ${s.error}`));
|
|
451
|
+
if (s.screenshotBeforePath) console.log(chalk6.dim(` screenshot: ${s.screenshotBeforePath}`));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async function runCommand(fileArg, opts) {
|
|
458
|
+
let url = opts.url;
|
|
459
|
+
const paths = fileArg ? [resolve2(fileArg)] : findZcFiles2(TESTS_DIR);
|
|
460
|
+
let contentFiles = [];
|
|
461
|
+
if (paths.length === 0) {
|
|
462
|
+
if (opts.bootstrap === false) {
|
|
463
|
+
console.error(chalk6.red(`No tests found at ${TESTS_DIR} and --no-bootstrap was passed.`));
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
if (!url) {
|
|
467
|
+
console.error(chalk6.red("No tests found and no --url provided to bootstrap from."));
|
|
468
|
+
console.error(chalk6.dim("Either: zerocheck run --url https://staging.example.com"));
|
|
469
|
+
console.error(chalk6.dim("Or add a .zc file to ./zerocheck/tests/ and rerun."));
|
|
470
|
+
process.exit(2);
|
|
471
|
+
}
|
|
472
|
+
contentFiles = await bootstrapTests(url);
|
|
473
|
+
} else {
|
|
474
|
+
contentFiles = paths;
|
|
475
|
+
}
|
|
476
|
+
const content = contentFiles.map((p) => readFileSync3(p, "utf-8")).join("\n\n");
|
|
477
|
+
console.log(chalk6.dim(`Starting run${url ? ` against ${url}` : ""}...`));
|
|
478
|
+
let start;
|
|
479
|
+
try {
|
|
480
|
+
start = await apiRequest("/v1/cli/runs", {
|
|
481
|
+
body: { url, content }
|
|
482
|
+
});
|
|
483
|
+
} catch (err) {
|
|
484
|
+
if (isApiError(err)) {
|
|
485
|
+
if (err.status === 401) {
|
|
486
|
+
console.error(chalk6.red("Not logged in.") + " Run `zerocheck login` first.");
|
|
487
|
+
process.exit(2);
|
|
488
|
+
}
|
|
489
|
+
if (err.status === 429) {
|
|
490
|
+
console.error(chalk6.red(err.message ?? "Rate limited."));
|
|
491
|
+
process.exit(3);
|
|
492
|
+
}
|
|
493
|
+
if (err.status === 400 && err.error === "parse_error") {
|
|
494
|
+
console.error(chalk6.red("Could not parse tests.") + " Run `zerocheck validate` to see details.");
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
console.error(chalk6.red(`Run start failed (${err.status}): ${err.message ?? err.error}`));
|
|
498
|
+
} else {
|
|
499
|
+
console.error(chalk6.red("Run start failed:"), err);
|
|
500
|
+
}
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
console.log(chalk6.dim(` run_id=${start.run_id} ${start.total_tests} test${start.total_tests === 1 ? "" : "s"}`));
|
|
504
|
+
console.log();
|
|
505
|
+
const stream = openRunStream(start.run_id, (event) => {
|
|
506
|
+
if (event.type === "run:test:completed") {
|
|
507
|
+
const d = event.data;
|
|
508
|
+
const mark = d.status === "pass" ? chalk6.green("\u2713") : chalk6.red("\u2717");
|
|
509
|
+
const name = typeof d.testName === "string" ? d.testName : "(unknown test)";
|
|
510
|
+
const dur = typeof d.duration === "number" ? ` ${chalk6.dim(`(${(d.duration / 1e3).toFixed(1)}s)`)}` : "";
|
|
511
|
+
console.log(`${mark} ${name}${dur}`);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
const deadline = Date.now() + MAX_POLL_MS;
|
|
515
|
+
try {
|
|
516
|
+
while (Date.now() < deadline) {
|
|
517
|
+
await sleep2(POLL_INTERVAL_MS);
|
|
518
|
+
try {
|
|
519
|
+
const detail = await apiRequest(`/v1/cli/runs/${start.run_id}`, { method: "GET" });
|
|
520
|
+
if (detail.status === "passed" || detail.status === "failed" || detail.status === "error") {
|
|
521
|
+
stream.close();
|
|
522
|
+
renderFinalReport(detail);
|
|
523
|
+
process.exit(detail.status === "passed" ? 0 : 1);
|
|
524
|
+
}
|
|
525
|
+
} catch (err) {
|
|
526
|
+
if (isApiError(err) && err.status === 404) continue;
|
|
527
|
+
throw err;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
console.error(chalk6.red("\nRun did not complete within the polling window."));
|
|
531
|
+
process.exit(1);
|
|
532
|
+
} finally {
|
|
533
|
+
stream.close();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/commands/doctor.ts
|
|
538
|
+
import { existsSync as existsSync4, statSync as statSync3 } from "fs";
|
|
539
|
+
import { platform, arch, release } from "os";
|
|
540
|
+
import chalk7 from "chalk";
|
|
541
|
+
var CLI_VERSION = "0.1.0";
|
|
542
|
+
function line(status, label, detail, hint) {
|
|
543
|
+
const icon = status === "ok" ? chalk7.green("\u2713") : status === "warn" ? chalk7.yellow("!") : chalk7.red("\u2717");
|
|
544
|
+
const line2 = `${icon} ${label}`;
|
|
545
|
+
console.log(detail ? `${line2} ${chalk7.dim(detail)}` : line2);
|
|
546
|
+
if (hint) console.log(chalk7.dim(" \u2192 " + hint));
|
|
547
|
+
}
|
|
548
|
+
function compareSemver(a, b) {
|
|
549
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
550
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
551
|
+
for (let i = 0; i < 3; i++) {
|
|
552
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
553
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
554
|
+
}
|
|
555
|
+
return 0;
|
|
556
|
+
}
|
|
557
|
+
function checkConfig() {
|
|
558
|
+
const path = configPath();
|
|
559
|
+
if (!existsSync4(path)) {
|
|
560
|
+
return { ok: false, detail: "not found", hint: "Run `zerocheck login`." };
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const stat = statSync3(path);
|
|
564
|
+
if ((stat.mode & 63) !== 0) {
|
|
565
|
+
return { ok: false, detail: `mode ${(stat.mode & 511).toString(8)}`, hint: `Run chmod 600 ${path}` };
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
return { ok: false, detail: err instanceof Error ? err.message : String(err), hint: "Check file permissions." };
|
|
569
|
+
}
|
|
570
|
+
return { ok: true, detail: path };
|
|
571
|
+
}
|
|
572
|
+
function checkToken() {
|
|
573
|
+
const cfg = loadConfig();
|
|
574
|
+
if (!cfg.access_token) {
|
|
575
|
+
return { ok: false, detail: "no token", hint: "Run `zerocheck login`." };
|
|
576
|
+
}
|
|
577
|
+
return { ok: true, detail: cfg.access_token.slice(0, 15) + "..." };
|
|
578
|
+
}
|
|
579
|
+
async function checkBackend() {
|
|
580
|
+
try {
|
|
581
|
+
const status = await apiRequest("/v1/cli/status", { method: "GET" });
|
|
582
|
+
return { ok: true, detail: `API v${status.api_version}`, status };
|
|
583
|
+
} catch (err) {
|
|
584
|
+
if (isApiError(err)) {
|
|
585
|
+
if (err.status === 401) {
|
|
586
|
+
return { ok: false, detail: "unauthorized", hint: "Session expired \u2014 run `zerocheck login` again." };
|
|
587
|
+
}
|
|
588
|
+
return { ok: false, detail: `HTTP ${err.status}`, hint: err.message ?? err.error };
|
|
589
|
+
}
|
|
590
|
+
return { ok: false, detail: err instanceof Error ? err.message : String(err), hint: `Can't reach ${apiBase()}. Check network or set ZEROCHECK_API.` };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function checkVersion(minVersion) {
|
|
594
|
+
if (compareSemver(CLI_VERSION, minVersion) >= 0) {
|
|
595
|
+
return { ok: true, detail: `cli ${CLI_VERSION} >= min ${minVersion}` };
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
ok: false,
|
|
599
|
+
detail: `cli ${CLI_VERSION} < min ${minVersion}`,
|
|
600
|
+
hint: "Upgrade: npm i -g zerocheck@latest"
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
async function doctorCommand() {
|
|
604
|
+
console.log(chalk7.bold("zerocheck doctor\n"));
|
|
605
|
+
console.log(chalk7.dim(` platform: ${platform()} ${arch()} (${release()})`));
|
|
606
|
+
console.log(chalk7.dim(` node: ${process.version}`));
|
|
607
|
+
console.log(chalk7.dim(` cli: ${CLI_VERSION}`));
|
|
608
|
+
console.log(chalk7.dim(` api base: ${apiBase()}
|
|
609
|
+
`));
|
|
610
|
+
let anyFailed = false;
|
|
611
|
+
const cfgCheck = checkConfig();
|
|
612
|
+
if (cfgCheck.ok) line("ok", "Local config", cfgCheck.detail);
|
|
613
|
+
else {
|
|
614
|
+
anyFailed = true;
|
|
615
|
+
line("fail", "Local config", cfgCheck.detail, cfgCheck.hint);
|
|
616
|
+
}
|
|
617
|
+
const tokenCheck = checkToken();
|
|
618
|
+
if (tokenCheck.ok) line("ok", "Token present", tokenCheck.detail);
|
|
619
|
+
else {
|
|
620
|
+
anyFailed = true;
|
|
621
|
+
line("fail", "Token present", tokenCheck.detail, tokenCheck.hint);
|
|
622
|
+
}
|
|
623
|
+
if (!tokenCheck.ok) {
|
|
624
|
+
console.log();
|
|
625
|
+
console.log(chalk7.yellow("Skipping remote checks \u2014 no token."));
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
const backendCheck = await checkBackend();
|
|
629
|
+
if (backendCheck.ok) line("ok", "Backend reachable", backendCheck.detail);
|
|
630
|
+
else {
|
|
631
|
+
anyFailed = true;
|
|
632
|
+
line("fail", "Backend reachable", backendCheck.detail, backendCheck.hint);
|
|
633
|
+
}
|
|
634
|
+
if (backendCheck.ok && backendCheck.status) {
|
|
635
|
+
const versionCheck = checkVersion(backendCheck.status.min_cli_version);
|
|
636
|
+
if (versionCheck.ok) line("ok", "CLI version", versionCheck.detail);
|
|
637
|
+
else {
|
|
638
|
+
anyFailed = true;
|
|
639
|
+
line("fail", "CLI version", versionCheck.detail, versionCheck.hint);
|
|
640
|
+
}
|
|
641
|
+
const project = backendCheck.status.project;
|
|
642
|
+
if (project) {
|
|
643
|
+
const stagingOk = !!project.staging_url;
|
|
644
|
+
if (stagingOk) line("ok", "Project staging URL", project.staging_url ?? "");
|
|
645
|
+
else line("warn", "Project staging URL", "not set", "Pass --url on run, or set in dashboard Settings.");
|
|
646
|
+
if (project.github_linked) line("ok", "GitHub linked");
|
|
647
|
+
else line("warn", "GitHub linked", "not linked", "Run `zerocheck link github` for PR automation.");
|
|
648
|
+
} else {
|
|
649
|
+
line("warn", "Project", "no active project");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
console.log();
|
|
653
|
+
if (anyFailed) {
|
|
654
|
+
console.log(chalk7.red("Some checks failed. Follow the hints above."));
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
console.log(chalk7.green("All checks passed."));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// src/commands/discover.ts
|
|
661
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
|
|
662
|
+
import { join as join5 } from "path";
|
|
663
|
+
import chalk8 from "chalk";
|
|
664
|
+
function sanitize3(name) {
|
|
665
|
+
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "test";
|
|
666
|
+
}
|
|
667
|
+
function sleep3(ms) {
|
|
668
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
669
|
+
}
|
|
670
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["completed", "cancelled", "error"]);
|
|
671
|
+
var POLL_INTERVAL_MS2 = 2500;
|
|
672
|
+
var MAX_WAIT_MS = 15 * 60 * 1e3;
|
|
673
|
+
async function discoverCommand(opts) {
|
|
674
|
+
if (!opts.url) {
|
|
675
|
+
console.error(chalk8.red("--url is required."));
|
|
676
|
+
process.exit(2);
|
|
677
|
+
}
|
|
678
|
+
const maxPages = opts.maxPages ? Math.min(100, Math.max(1, parseInt(opts.maxPages, 10) || 20)) : 20;
|
|
679
|
+
const maxDepth = opts.maxDepth ? Math.min(5, Math.max(1, parseInt(opts.maxDepth, 10) || 3)) : 3;
|
|
680
|
+
console.log(chalk8.dim(`Discovering ${opts.url} (max ${maxPages} pages, depth ${maxDepth})...`));
|
|
681
|
+
let start;
|
|
682
|
+
try {
|
|
683
|
+
start = await apiRequest("/v1/cli/discover", {
|
|
684
|
+
body: { url: opts.url, max_pages: maxPages, max_depth: maxDepth }
|
|
685
|
+
});
|
|
686
|
+
} catch (err) {
|
|
687
|
+
if (isApiError(err)) {
|
|
688
|
+
if (err.status === 401) {
|
|
689
|
+
console.error(chalk8.red("Not logged in.") + " Run `zerocheck login` first.");
|
|
690
|
+
process.exit(2);
|
|
691
|
+
}
|
|
692
|
+
if (err.status === 429) {
|
|
693
|
+
console.error(chalk8.red(err.message ?? "Rate limited."));
|
|
694
|
+
process.exit(3);
|
|
695
|
+
}
|
|
696
|
+
console.error(chalk8.red(`Discover start failed (${err.status}): ${err.message ?? err.error}`));
|
|
697
|
+
} else {
|
|
698
|
+
console.error(chalk8.red("Discover start failed:"), err);
|
|
699
|
+
}
|
|
700
|
+
process.exit(1);
|
|
701
|
+
}
|
|
702
|
+
console.log(chalk8.dim(` session_id=${start.session_id}`));
|
|
703
|
+
const deadline = Date.now() + MAX_WAIT_MS;
|
|
704
|
+
let lastRoutes = -1;
|
|
705
|
+
let lastTests = -1;
|
|
706
|
+
while (Date.now() < deadline) {
|
|
707
|
+
await sleep3(POLL_INTERVAL_MS2);
|
|
708
|
+
let detail;
|
|
709
|
+
try {
|
|
710
|
+
detail = await apiRequest(`/v1/cli/discover/${start.session_id}`, { method: "GET" });
|
|
711
|
+
} catch (err) {
|
|
712
|
+
if (isApiError(err) && err.status === 404) continue;
|
|
713
|
+
throw err;
|
|
714
|
+
}
|
|
715
|
+
const { status, pages_visited, routes_discovered, tests_generated, error } = detail.session;
|
|
716
|
+
if (routes_discovered !== lastRoutes || tests_generated !== lastTests) {
|
|
717
|
+
process.stdout.write(
|
|
718
|
+
`\r ${chalk8.dim(status.padEnd(11))} pages=${pages_visited} routes=${routes_discovered} tests=${tests_generated} `
|
|
719
|
+
);
|
|
720
|
+
lastRoutes = routes_discovered;
|
|
721
|
+
lastTests = tests_generated;
|
|
722
|
+
}
|
|
723
|
+
if (TERMINAL_STATUSES.has(status)) {
|
|
724
|
+
process.stdout.write("\n\n");
|
|
725
|
+
if (status === "error") {
|
|
726
|
+
console.error(chalk8.red(`Discovery failed: ${error ?? "unknown error"}`));
|
|
727
|
+
process.exit(1);
|
|
728
|
+
}
|
|
729
|
+
if (status === "cancelled") {
|
|
730
|
+
console.log(chalk8.yellow("Discovery cancelled."));
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
console.log(chalk8.green(`\u2713 Discovered ${detail.routes.length} route${detail.routes.length === 1 ? "" : "s"}, generated ${detail.tests.length} test${detail.tests.length === 1 ? "" : "s"}.`));
|
|
734
|
+
console.log();
|
|
735
|
+
for (const test of detail.tests) {
|
|
736
|
+
console.log(chalk8.bold(` ${test.name}`) + chalk8.dim(` ${test.target_route ?? ""}`));
|
|
737
|
+
}
|
|
738
|
+
if (opts.save && detail.tests.length > 0) {
|
|
739
|
+
const outDir = opts.output ?? "./zerocheck/tests";
|
|
740
|
+
mkdirSync4(outDir, { recursive: true });
|
|
741
|
+
const written = [];
|
|
742
|
+
for (const test of detail.tests) {
|
|
743
|
+
const path = join5(outDir, `${sanitize3(test.name)}.zc`);
|
|
744
|
+
if (existsSync5(path)) continue;
|
|
745
|
+
writeFileSync4(path, test.yaml, "utf-8");
|
|
746
|
+
written.push(path);
|
|
747
|
+
}
|
|
748
|
+
console.log();
|
|
749
|
+
console.log(chalk8.green(`\u2713 Wrote ${written.length} file${written.length === 1 ? "" : "s"} to ${outDir}`));
|
|
750
|
+
for (const p of written) console.log(chalk8.dim(` ${p}`));
|
|
751
|
+
} else if (!opts.save) {
|
|
752
|
+
console.log();
|
|
753
|
+
console.log(chalk8.dim("Rerun with --save to write tests to ./zerocheck/tests/."));
|
|
754
|
+
console.log(chalk8.dim("Or review + approve via the dashboard /generated-tests page."));
|
|
755
|
+
}
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
console.error(chalk8.red("\nDiscovery did not complete within the polling window."));
|
|
760
|
+
process.exit(1);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/index.ts
|
|
764
|
+
var program = new Command();
|
|
765
|
+
program.name("zerocheck").description("Zerocheck \u2014 AI-powered E2E testing. Auth to Zerocheck cloud; your code stays local.").version("0.1.0");
|
|
766
|
+
program.command("login").description("Authenticate this device with Zerocheck").action(loginCommand);
|
|
767
|
+
program.command("logout").description("Revoke this device's token and clear local config").action(logoutCommand);
|
|
768
|
+
program.command("whoami").description("Show authentication state + linked project").action(whoamiCommand);
|
|
769
|
+
program.command("validate [path]").description("Lint .zc files (default: ./zerocheck/tests)").action(validateCommand);
|
|
770
|
+
program.command("generate").description("Generate .zc tests from a URL using hosted AI").requiredOption("--url <url>", "Target URL to generate tests against").option("--description <text>", "Short hint on what kind of tests you want").option("--max-tests <n>", "Maximum tests to generate (1-10)", "3").option("--save", "Write generated tests to disk at ./zerocheck/tests/").option("--output <dir>", "Override output directory", "./zerocheck/tests").action(generateCommand);
|
|
771
|
+
program.command("run [file]").description("Run .zc tests. On an empty repo, generates starter tests first.").option("--url <url>", "Target URL (overrides project staging URL)").option("--no-bootstrap", "Do not auto-generate starter tests when repo is empty").action(runCommand);
|
|
772
|
+
program.command("doctor").description("Health check: config, token, backend reachability, version compat").action(doctorCommand);
|
|
773
|
+
program.command("discover").description("Crawl a URL and generate an initial .zc test suite").requiredOption("--url <url>", "URL to start crawling from").option("--max-pages <n>", "Maximum pages to crawl (1-100)", "20").option("--max-depth <n>", "Maximum crawl depth (1-5)", "3").option("--save", "Write generated tests to ./zerocheck/tests/").option("--output <dir>", "Override output directory", "./zerocheck/tests").action(discoverCommand);
|
|
774
|
+
program.parseAsync().catch((err) => {
|
|
775
|
+
console.error(err);
|
|
776
|
+
process.exit(1);
|
|
777
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zerocheck",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Zerocheck CLI — AI-powered E2E testing. Auth-only, hosted execution.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zerocheck": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"dev": "tsx src/index.ts",
|
|
17
|
+
"check": "tsc --noEmit",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"chalk": "^5.4.1",
|
|
22
|
+
"commander": "^13.1.0",
|
|
23
|
+
"ora": "^8.2.0",
|
|
24
|
+
"undici": "^7.10.0",
|
|
25
|
+
"ws": "^8.20.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.15.0",
|
|
29
|
+
"@types/ws": "^8.18.1",
|
|
30
|
+
"tsup": "^8.4.0",
|
|
31
|
+
"tsx": "^4.19.4",
|
|
32
|
+
"typescript": "^5.7.3"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|