quackdev 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/quack.js +64 -0
- package/package.json +38 -0
- package/src/commands/init.js +165 -0
- package/src/commands/login.js +61 -0
- package/src/commands/logout.js +22 -0
- package/src/commands/scan.js +169 -0
- package/src/lib/api.js +40 -0
- package/src/lib/config.js +36 -0
- package/src/lib/constants.js +5 -0
- package/src/lib/logger.js +14 -0
package/bin/quack.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from "commander";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name("quack")
|
|
13
|
+
.description("Zero-config bug detection for your CI")
|
|
14
|
+
.version(pkg.version, "-v, --version");
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.command("login")
|
|
18
|
+
.description("Authenticate with your Quack account")
|
|
19
|
+
.action(async () => {
|
|
20
|
+
const { login } = await import("../src/commands/login.js");
|
|
21
|
+
await login();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.command("logout")
|
|
26
|
+
.description("Sign out of your Quack account")
|
|
27
|
+
.action(async () => {
|
|
28
|
+
const { logout } = await import("../src/commands/logout.js");
|
|
29
|
+
await logout();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.command("init")
|
|
34
|
+
.description("Initialize Quack in the current project")
|
|
35
|
+
.action(async () => {
|
|
36
|
+
const { init } = await import("../src/commands/init.js");
|
|
37
|
+
await init();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.command("scan")
|
|
42
|
+
.description("Run a Quack scan on the current project")
|
|
43
|
+
.option("--ci", "Run in CI mode (non-interactive, exit code reflects results)")
|
|
44
|
+
.action(async (opts) => {
|
|
45
|
+
const { scan } = await import("../src/commands/scan.js");
|
|
46
|
+
await scan({ ci: opts.ci });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
program
|
|
50
|
+
.command("whoami")
|
|
51
|
+
.description("Show the currently authenticated user")
|
|
52
|
+
.action(async () => {
|
|
53
|
+
const { isAuthenticated, getLogin } = await import("../src/lib/config.js");
|
|
54
|
+
const { log } = await import("../src/lib/logger.js");
|
|
55
|
+
log.blank();
|
|
56
|
+
if (isAuthenticated()) {
|
|
57
|
+
log.success(`Logged in as ${getLogin() || "(unknown)"}`);
|
|
58
|
+
} else {
|
|
59
|
+
log.warn("Not logged in.");
|
|
60
|
+
}
|
|
61
|
+
log.blank();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quackdev",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-config bug detection for your CI. Discovers user flows, runs them in a headless browser, and surfaces bugs automatically.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"quack": "bin/quack.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"testing",
|
|
15
|
+
"ci",
|
|
16
|
+
"e2e",
|
|
17
|
+
"bug-detection",
|
|
18
|
+
"quack"
|
|
19
|
+
],
|
|
20
|
+
"author": "Quack",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/quackdev/quack.git",
|
|
25
|
+
"directory": "cli"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://quack.dev",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"chalk": "^5.4.1",
|
|
33
|
+
"commander": "^13.1.0",
|
|
34
|
+
"conf": "^13.1.0",
|
|
35
|
+
"open": "^10.1.2",
|
|
36
|
+
"ora": "^8.2.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { CONFIG_FILE, WORKFLOW_PATH } from "../lib/constants.js";
|
|
6
|
+
import { isAuthenticated, getToken } from "../lib/config.js";
|
|
7
|
+
import { apiPost } from "../lib/api.js";
|
|
8
|
+
import { log } from "../lib/logger.js";
|
|
9
|
+
|
|
10
|
+
const WORKFLOW_TEMPLATE = `name: Quack Bug Detection
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
push:
|
|
14
|
+
branches: [main, master]
|
|
15
|
+
pull_request:
|
|
16
|
+
branches: [main, master]
|
|
17
|
+
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
checks: write
|
|
21
|
+
pull-requests: write
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
quack:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
|
|
29
|
+
- name: Setup Node.js
|
|
30
|
+
uses: actions/setup-node@v4
|
|
31
|
+
with:
|
|
32
|
+
node-version: "20"
|
|
33
|
+
|
|
34
|
+
- name: Install Quack
|
|
35
|
+
run: npm install -g @luisf_mc/quack
|
|
36
|
+
|
|
37
|
+
- name: Run Quack Scan
|
|
38
|
+
run: quack scan --ci
|
|
39
|
+
env:
|
|
40
|
+
QUACK_TOKEN: \${{ secrets.QUACK_TOKEN }}
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
function findProjectRoot(startDir) {
|
|
44
|
+
let dir = startDir;
|
|
45
|
+
while (dir !== path.dirname(dir)) {
|
|
46
|
+
if (
|
|
47
|
+
fs.existsSync(path.join(dir, "package.json")) ||
|
|
48
|
+
fs.existsSync(path.join(dir, ".git"))
|
|
49
|
+
) {
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
dir = path.dirname(dir);
|
|
53
|
+
}
|
|
54
|
+
return startDir;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function detectFramework(projectRoot) {
|
|
58
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
59
|
+
if (!fs.existsSync(pkgPath)) return { name: "unknown", entryPoints: [] };
|
|
60
|
+
|
|
61
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
62
|
+
const deps = {
|
|
63
|
+
...pkg.dependencies,
|
|
64
|
+
...pkg.devDependencies,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (deps["next"]) return { name: "Next.js", entryPoints: ["/", "/api"] };
|
|
68
|
+
if (deps["nuxt"]) return { name: "Nuxt", entryPoints: ["/"] };
|
|
69
|
+
if (deps["@angular/core"]) return { name: "Angular", entryPoints: ["/"] };
|
|
70
|
+
if (deps["svelte"] || deps["@sveltejs/kit"]) return { name: "SvelteKit", entryPoints: ["/"] };
|
|
71
|
+
if (deps["react"]) return { name: "React", entryPoints: ["/"] };
|
|
72
|
+
if (deps["vue"]) return { name: "Vue", entryPoints: ["/"] };
|
|
73
|
+
if (deps["express"]) return { name: "Express", entryPoints: ["/"] };
|
|
74
|
+
|
|
75
|
+
return { name: "unknown", entryPoints: ["/"] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function init() {
|
|
79
|
+
log.blank();
|
|
80
|
+
log.brand(" quack init");
|
|
81
|
+
log.blank();
|
|
82
|
+
|
|
83
|
+
if (!isAuthenticated()) {
|
|
84
|
+
log.error(`Not authenticated. Run ${chalk.cyan("quack login")} first.`);
|
|
85
|
+
log.blank();
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
90
|
+
const framework = detectFramework(projectRoot);
|
|
91
|
+
|
|
92
|
+
log.step(1, `Detected project at ${chalk.dim(projectRoot)}`);
|
|
93
|
+
if (framework.name !== "unknown") {
|
|
94
|
+
log.success(`Framework: ${chalk.bold(framework.name)}`);
|
|
95
|
+
}
|
|
96
|
+
log.blank();
|
|
97
|
+
|
|
98
|
+
// Write .quack.json config
|
|
99
|
+
const spinner1 = ora("Creating quack config…").start();
|
|
100
|
+
const configPath = path.join(projectRoot, CONFIG_FILE);
|
|
101
|
+
|
|
102
|
+
const quackConfig = {
|
|
103
|
+
version: 1,
|
|
104
|
+
framework: framework.name,
|
|
105
|
+
entryPoints: framework.entryPoints,
|
|
106
|
+
ignore: ["node_modules", ".git", "dist", "build", ".next"],
|
|
107
|
+
headless: true,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
fs.writeFileSync(configPath, JSON.stringify(quackConfig, null, 2) + "\n");
|
|
111
|
+
spinner1.succeed(`Created ${chalk.cyan(CONFIG_FILE)}`);
|
|
112
|
+
|
|
113
|
+
// Write GitHub Actions workflow
|
|
114
|
+
const spinner2 = ora("Setting up CI workflow…").start();
|
|
115
|
+
const workflowFullPath = path.join(projectRoot, WORKFLOW_PATH);
|
|
116
|
+
const workflowDir = path.dirname(workflowFullPath);
|
|
117
|
+
|
|
118
|
+
if (!fs.existsSync(workflowDir)) {
|
|
119
|
+
fs.mkdirSync(workflowDir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fs.writeFileSync(workflowFullPath, WORKFLOW_TEMPLATE);
|
|
123
|
+
spinner2.succeed(`Created ${chalk.cyan(WORKFLOW_PATH)}`);
|
|
124
|
+
|
|
125
|
+
// Register project with API (non-blocking — gracefully fails if API is down)
|
|
126
|
+
const spinner3 = ora("Registering project…").start();
|
|
127
|
+
try {
|
|
128
|
+
await apiPost("/projects/register", {
|
|
129
|
+
root: projectRoot,
|
|
130
|
+
framework: framework.name,
|
|
131
|
+
entryPoints: framework.entryPoints,
|
|
132
|
+
});
|
|
133
|
+
spinner3.succeed("Project registered with Quack");
|
|
134
|
+
} catch {
|
|
135
|
+
spinner3.warn("Could not reach Quack API — project will register on first scan");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
log.blank();
|
|
139
|
+
log.success(chalk.bold("You're all set!"));
|
|
140
|
+
log.blank();
|
|
141
|
+
log.dim(
|
|
142
|
+
`Quack will run on every push and PR via GitHub Actions.`
|
|
143
|
+
);
|
|
144
|
+
log.dim(
|
|
145
|
+
`Bugs will surface as check annotations on your commits.`
|
|
146
|
+
);
|
|
147
|
+
log.blank();
|
|
148
|
+
log.info(
|
|
149
|
+
`Add ${chalk.cyan("QUACK_TOKEN")} to your repo secrets:`
|
|
150
|
+
);
|
|
151
|
+
log.dim(
|
|
152
|
+
`${chalk.dim("→")} Settings → Secrets → Actions → ${chalk.bold("New repository secret")}`
|
|
153
|
+
);
|
|
154
|
+
log.dim(
|
|
155
|
+
` Name: ${chalk.bold("QUACK_TOKEN")}`
|
|
156
|
+
);
|
|
157
|
+
log.dim(
|
|
158
|
+
` Value: ${chalk.hex("#F6AD3C")(getToken().slice(0, 6) + "••••••••")}`
|
|
159
|
+
);
|
|
160
|
+
log.blank();
|
|
161
|
+
log.info(
|
|
162
|
+
`Or run ${chalk.cyan("quack scan")} to test locally now.`
|
|
163
|
+
);
|
|
164
|
+
log.blank();
|
|
165
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import open from "open";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { DASHBOARD_URL } from "../lib/constants.js";
|
|
6
|
+
import { setAuth, isAuthenticated, getLogin } from "../lib/config.js";
|
|
7
|
+
import { pollAuth } from "../lib/api.js";
|
|
8
|
+
import { log } from "../lib/logger.js";
|
|
9
|
+
|
|
10
|
+
export async function login() {
|
|
11
|
+
log.blank();
|
|
12
|
+
log.brand(" quack login");
|
|
13
|
+
log.blank();
|
|
14
|
+
|
|
15
|
+
if (isAuthenticated()) {
|
|
16
|
+
const currentLogin = getLogin();
|
|
17
|
+
log.success(`Already authenticated${currentLogin ? ` as ${chalk.bold(currentLogin)}` : ""}.`);
|
|
18
|
+
log.dim(`Run ${chalk.cyan("quack logout")} to sign out first.`);
|
|
19
|
+
log.blank();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sessionId = crypto.randomUUID();
|
|
24
|
+
const authUrl = `${DASHBOARD_URL}/auth/cli?session=${sessionId}`;
|
|
25
|
+
|
|
26
|
+
log.info("Opening your browser to authenticate…");
|
|
27
|
+
log.dim(chalk.underline(authUrl));
|
|
28
|
+
log.blank();
|
|
29
|
+
|
|
30
|
+
await open(authUrl);
|
|
31
|
+
|
|
32
|
+
const spinner = ora({
|
|
33
|
+
text: "Waiting for authentication…",
|
|
34
|
+
color: "yellow",
|
|
35
|
+
}).start();
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const data = await pollAuth(sessionId);
|
|
39
|
+
|
|
40
|
+
setAuth({
|
|
41
|
+
token: data.token,
|
|
42
|
+
userId: data.userId,
|
|
43
|
+
login: data.login,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
spinner.succeed(`Authenticated as ${chalk.bold(data.login)}`);
|
|
47
|
+
log.blank();
|
|
48
|
+
|
|
49
|
+
const masked = data.token.slice(0, 6) + "••••••••";
|
|
50
|
+
log.dim(`Token: ${chalk.hex("#F6AD3C")(masked)}`);
|
|
51
|
+
log.dim(`Stored in your system keychain.`);
|
|
52
|
+
log.blank();
|
|
53
|
+
log.info(`Next, run ${chalk.cyan("quack init")} in your project.`);
|
|
54
|
+
log.blank();
|
|
55
|
+
} catch (err) {
|
|
56
|
+
spinner.fail("Authentication failed.");
|
|
57
|
+
log.error(err.message);
|
|
58
|
+
log.blank();
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { clearAuth, isAuthenticated, getLogin } from "../lib/config.js";
|
|
3
|
+
import { log } from "../lib/logger.js";
|
|
4
|
+
|
|
5
|
+
export async function logout() {
|
|
6
|
+
log.blank();
|
|
7
|
+
|
|
8
|
+
if (!isAuthenticated()) {
|
|
9
|
+
log.warn("You're not currently logged in.");
|
|
10
|
+
log.blank();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const login = getLogin();
|
|
15
|
+
clearAuth();
|
|
16
|
+
|
|
17
|
+
log.success(
|
|
18
|
+
`Signed out${login ? ` from ${chalk.bold(login)}` : ""}.`
|
|
19
|
+
);
|
|
20
|
+
log.dim(`Run ${chalk.cyan("quack login")} to authenticate again.`);
|
|
21
|
+
log.blank();
|
|
22
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import { CONFIG_FILE } from "../lib/constants.js";
|
|
6
|
+
import { isAuthenticated } from "../lib/config.js";
|
|
7
|
+
import { apiPost } from "../lib/api.js";
|
|
8
|
+
import { log } from "../lib/logger.js";
|
|
9
|
+
|
|
10
|
+
function loadConfig() {
|
|
11
|
+
let dir = process.cwd();
|
|
12
|
+
while (dir !== path.dirname(dir)) {
|
|
13
|
+
const configPath = path.join(dir, CONFIG_FILE);
|
|
14
|
+
if (fs.existsSync(configPath)) {
|
|
15
|
+
return {
|
|
16
|
+
root: dir,
|
|
17
|
+
config: JSON.parse(fs.readFileSync(configPath, "utf-8")),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
dir = path.dirname(dir);
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function collectSourceFiles(root, ignore) {
|
|
26
|
+
const exts = new Set([".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"]);
|
|
27
|
+
const results = [];
|
|
28
|
+
|
|
29
|
+
function walk(dir) {
|
|
30
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
31
|
+
const full = path.join(dir, entry.name);
|
|
32
|
+
const rel = path.relative(root, full);
|
|
33
|
+
|
|
34
|
+
if (ignore.some((pattern) => rel.startsWith(pattern))) continue;
|
|
35
|
+
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
walk(full);
|
|
38
|
+
} else if (exts.has(path.extname(entry.name))) {
|
|
39
|
+
results.push(rel);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
walk(root);
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function scan({ ci = false } = {}) {
|
|
49
|
+
log.blank();
|
|
50
|
+
log.brand(" quack scan");
|
|
51
|
+
log.blank();
|
|
52
|
+
|
|
53
|
+
if (!isAuthenticated()) {
|
|
54
|
+
if (ci && process.env.QUACK_TOKEN) {
|
|
55
|
+
log.dim("Using QUACK_TOKEN from environment.");
|
|
56
|
+
} else {
|
|
57
|
+
log.error(`Not authenticated. Run ${chalk.cyan("quack login")} first.`);
|
|
58
|
+
log.blank();
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const project = loadConfig();
|
|
64
|
+
if (!project) {
|
|
65
|
+
log.error(
|
|
66
|
+
`No ${chalk.cyan(CONFIG_FILE)} found. Run ${chalk.cyan("quack init")} first.`
|
|
67
|
+
);
|
|
68
|
+
log.blank();
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { root, config } = project;
|
|
73
|
+
|
|
74
|
+
// Phase 1: Analyze
|
|
75
|
+
const spinner1 = ora("Analyzing codebase…").start();
|
|
76
|
+
const files = collectSourceFiles(root, config.ignore || []);
|
|
77
|
+
spinner1.succeed(
|
|
78
|
+
`Analyzed ${chalk.bold(files.length)} source files (${chalk.dim(config.framework)})`
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Phase 2: Discover flows
|
|
82
|
+
const spinner2 = ora("Discovering user flows…").start();
|
|
83
|
+
try {
|
|
84
|
+
const discovery = await apiPost("/scan/discover", {
|
|
85
|
+
framework: config.framework,
|
|
86
|
+
entryPoints: config.entryPoints,
|
|
87
|
+
files: files.slice(0, 200),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const flowCount = discovery.flows?.length || 0;
|
|
91
|
+
spinner2.succeed(
|
|
92
|
+
`Discovered ${chalk.bold(flowCount)} user flow${flowCount !== 1 ? "s" : ""}`
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (discovery.flows) {
|
|
96
|
+
for (const flow of discovery.flows) {
|
|
97
|
+
log.dim(
|
|
98
|
+
` ${chalk.hex("#F6AD3C")("→")} ${flow.name} (${flow.entry}, ${flow.steps} steps)`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
log.blank();
|
|
104
|
+
|
|
105
|
+
// Phase 3: Generate tests
|
|
106
|
+
const spinner3 = ora("Generating test cases…").start();
|
|
107
|
+
const generation = await apiPost("/scan/generate", {
|
|
108
|
+
flows: discovery.flows,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const testCount = generation.tests?.length || 0;
|
|
112
|
+
spinner3.succeed(
|
|
113
|
+
`Generated ${chalk.bold(testCount)} test${testCount !== 1 ? "s" : ""}`
|
|
114
|
+
);
|
|
115
|
+
log.blank();
|
|
116
|
+
|
|
117
|
+
// Phase 4: Run tests
|
|
118
|
+
const spinner4 = ora("Running tests in headless browser…").start();
|
|
119
|
+
const results = await apiPost("/scan/run", {
|
|
120
|
+
tests: generation.tests,
|
|
121
|
+
headless: config.headless,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const passed = results.results?.filter((r) => r.status === "passed").length || 0;
|
|
125
|
+
const failed = results.results?.filter((r) => r.status === "failed").length || 0;
|
|
126
|
+
|
|
127
|
+
if (failed > 0) {
|
|
128
|
+
spinner4.fail(
|
|
129
|
+
`${chalk.bold(passed)} passed, ${chalk.red.bold(failed)} failed`
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
spinner4.succeed(
|
|
133
|
+
`All ${chalk.bold(passed)} tests passed`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
log.blank();
|
|
138
|
+
|
|
139
|
+
if (results.bugs?.length) {
|
|
140
|
+
log.error(
|
|
141
|
+
chalk.bold(`${results.bugs.length} bug${results.bugs.length !== 1 ? "s" : ""} detected:`)
|
|
142
|
+
);
|
|
143
|
+
log.blank();
|
|
144
|
+
for (const bug of results.bugs) {
|
|
145
|
+
log.dim(` ${chalk.red("●")} ${chalk.bold(bug.title)}`);
|
|
146
|
+
log.dim(` ${bug.description}`);
|
|
147
|
+
log.dim(` ${chalk.dim("at")} ${chalk.hex("#F6AD3C")(bug.location)}`);
|
|
148
|
+
log.blank();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (ci) {
|
|
153
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
spinner2.fail("Scan failed");
|
|
157
|
+
log.error(err.message);
|
|
158
|
+
log.blank();
|
|
159
|
+
|
|
160
|
+
if (err.message.includes("API error")) {
|
|
161
|
+
log.dim(
|
|
162
|
+
"The Quack API may be unavailable. Check https://status.quack.dev"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
log.blank();
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { API_BASE } from "./constants.js";
|
|
2
|
+
import { getToken } from "./config.js";
|
|
3
|
+
|
|
4
|
+
function headers() {
|
|
5
|
+
const token = getToken();
|
|
6
|
+
return {
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function apiGet(path) {
|
|
13
|
+
const res = await fetch(`${API_BASE}${path}`, { headers: headers() });
|
|
14
|
+
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
|
15
|
+
return res.json();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function apiPost(path, body) {
|
|
19
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: headers(),
|
|
22
|
+
body: JSON.stringify(body),
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
|
25
|
+
return res.json();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function pollAuth(sessionId, { interval = 2000, timeout = 120000 } = {}) {
|
|
29
|
+
const start = Date.now();
|
|
30
|
+
while (Date.now() - start < timeout) {
|
|
31
|
+
try {
|
|
32
|
+
const data = await apiGet(`/auth/poll?session=${sessionId}`);
|
|
33
|
+
if (data.token) return data;
|
|
34
|
+
} catch {
|
|
35
|
+
// not ready yet
|
|
36
|
+
}
|
|
37
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
38
|
+
}
|
|
39
|
+
throw new Error("Authentication timed out. Please try again.");
|
|
40
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Conf from "conf";
|
|
2
|
+
import { APP_NAME } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
const config = new Conf({
|
|
5
|
+
projectName: APP_NAME,
|
|
6
|
+
schema: {
|
|
7
|
+
token: { type: "string", default: "" },
|
|
8
|
+
userId: { type: "string", default: "" },
|
|
9
|
+
login: { type: "string", default: "" },
|
|
10
|
+
apiUrl: { type: "string", default: "https://api.quack.dev" },
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function getToken() {
|
|
15
|
+
return config.get("token");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function setAuth({ token, userId, login }) {
|
|
19
|
+
config.set("token", token);
|
|
20
|
+
if (userId) config.set("userId", userId);
|
|
21
|
+
if (login) config.set("login", login);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearAuth() {
|
|
25
|
+
config.clear();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isAuthenticated() {
|
|
29
|
+
return Boolean(config.get("token"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getLogin() {
|
|
33
|
+
return config.get("login");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { config };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const APP_NAME = "quack";
|
|
2
|
+
export const API_BASE = process.env.QUACK_API_URL || "https://api.quack.dev";
|
|
3
|
+
export const DASHBOARD_URL = process.env.QUACK_DASHBOARD_URL || "https://app.quack.dev";
|
|
4
|
+
export const CONFIG_FILE = ".quack.json";
|
|
5
|
+
export const WORKFLOW_PATH = ".github/workflows/quack.yml";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
const duck = chalk.hex("#F6AD3C")("🦆");
|
|
4
|
+
|
|
5
|
+
export const log = {
|
|
6
|
+
info: (msg) => console.log(`${duck} ${msg}`),
|
|
7
|
+
success: (msg) => console.log(`${chalk.green("✔")} ${msg}`),
|
|
8
|
+
warn: (msg) => console.log(`${chalk.yellow("⚠")} ${msg}`),
|
|
9
|
+
error: (msg) => console.error(`${chalk.red("✖")} ${msg}`),
|
|
10
|
+
dim: (msg) => console.log(chalk.dim(` ${msg}`)),
|
|
11
|
+
blank: () => console.log(),
|
|
12
|
+
step: (n, msg) => console.log(`${chalk.dim(`[${n}]`)} ${msg}`),
|
|
13
|
+
brand: (msg) => console.log(chalk.hex("#F6AD3C").bold(msg)),
|
|
14
|
+
};
|