skopix 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +65 -0
- package/.github/workflows/docker.yml +78 -0
- package/cli/commands/agent.js +378 -0
- package/cli/commands/config.js +67 -0
- package/cli/commands/dashboard.js +3524 -0
- package/cli/commands/init.js +190 -0
- package/cli/commands/report.js +41 -0
- package/cli/commands/run.js +350 -0
- package/cli/index.js +85 -0
- package/cli/ui.js +126 -0
- package/core/auth.js +148 -0
- package/core/browser.js +1049 -0
- package/core/credentials.js +47 -0
- package/core/db.js +503 -0
- package/core/llm.js +641 -0
- package/core/recorder.js +653 -0
- package/core/reporter.js +282 -0
- package/core/tracker.js +768 -0
- package/package.json +54 -0
- package/web/app/index.html +5937 -0
- package/web/index.html +644 -0
- package/web/invite.html +244 -0
- package/web/login.html +271 -0
- package/web/reset.html +222 -0
- package/web/setup.html +300 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
export async function initCommand() {
|
|
10
|
+
console.log(chalk.cyan.bold('\n Skopix Setup\n'));
|
|
11
|
+
console.log(chalk.dim(' This will create a .skopix.env file in your current directory.\n'));
|
|
12
|
+
|
|
13
|
+
const answers = await inquirer.prompt([
|
|
14
|
+
{
|
|
15
|
+
type: 'list',
|
|
16
|
+
name: 'provider',
|
|
17
|
+
message: 'Which LLM provider would you like to use?',
|
|
18
|
+
choices: [
|
|
19
|
+
{ name: 'Google Gemini (free tier available)', value: 'gemini' },
|
|
20
|
+
{ name: 'Ollama (local, completely free)', value: 'ollama' },
|
|
21
|
+
{ name: 'OpenAI GPT-4 Vision', value: 'openai' },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: 'input',
|
|
26
|
+
name: 'geminiKey',
|
|
27
|
+
message: 'Enter your Gemini API key:',
|
|
28
|
+
when: (a) => a.provider === 'gemini',
|
|
29
|
+
validate: (v) => v.trim().length > 0 || 'API key is required',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
type: 'input',
|
|
33
|
+
name: 'ollamaUrl',
|
|
34
|
+
message: 'Ollama base URL:',
|
|
35
|
+
default: 'http://localhost:11434',
|
|
36
|
+
when: (a) => a.provider === 'ollama',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: 'input',
|
|
40
|
+
name: 'ollamaModel',
|
|
41
|
+
message: 'Ollama model name (must support vision or text):',
|
|
42
|
+
default: 'llama3.1',
|
|
43
|
+
when: (a) => a.provider === 'ollama',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'input',
|
|
47
|
+
name: 'openaiKey',
|
|
48
|
+
message: 'Enter your OpenAI API key:',
|
|
49
|
+
when: (a) => a.provider === 'openai',
|
|
50
|
+
validate: (v) => v.trim().length > 0 || 'API key is required',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: 'confirm',
|
|
54
|
+
name: 'setupJira',
|
|
55
|
+
message: 'Would you like to set up Jira integration?',
|
|
56
|
+
default: false,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: 'input',
|
|
60
|
+
name: 'jiraUrl',
|
|
61
|
+
message: 'Jira base URL (e.g. https://yourorg.atlassian.net):',
|
|
62
|
+
when: (a) => a.setupJira,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
type: 'input',
|
|
66
|
+
name: 'jiraEmail',
|
|
67
|
+
message: 'Jira account email:',
|
|
68
|
+
when: (a) => a.setupJira,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'password',
|
|
72
|
+
name: 'jiraToken',
|
|
73
|
+
message: 'Jira API token (https://id.atlassian.com/manage-profile/security/api-tokens):',
|
|
74
|
+
when: (a) => a.setupJira,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: 'input',
|
|
78
|
+
name: 'jiraProject',
|
|
79
|
+
message: 'Jira project key (e.g. QA, BUG):',
|
|
80
|
+
when: (a) => a.setupJira,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
type: 'confirm',
|
|
84
|
+
name: 'setupLinear',
|
|
85
|
+
message: 'Would you like to set up Linear integration?',
|
|
86
|
+
default: false,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: 'password',
|
|
90
|
+
name: 'linearKey',
|
|
91
|
+
message: 'Linear API key:',
|
|
92
|
+
when: (a) => a.setupLinear,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
type: 'input',
|
|
96
|
+
name: 'linearTeam',
|
|
97
|
+
message: 'Linear team ID:',
|
|
98
|
+
when: (a) => a.setupLinear,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: 'confirm',
|
|
102
|
+
name: 'setupGithub',
|
|
103
|
+
message: 'Would you like to set up GitHub Issues integration?',
|
|
104
|
+
default: false,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: 'input',
|
|
108
|
+
name: 'githubRepo',
|
|
109
|
+
message: 'GitHub repo (e.g. owner/repo):',
|
|
110
|
+
when: (a) => a.setupGithub,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: 'password',
|
|
114
|
+
name: 'githubToken',
|
|
115
|
+
message: 'GitHub personal access token:',
|
|
116
|
+
when: (a) => a.setupGithub,
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
// Build env file content
|
|
121
|
+
const lines = [
|
|
122
|
+
'# Skopix Configuration',
|
|
123
|
+
'# Generated by skopix init',
|
|
124
|
+
'',
|
|
125
|
+
`SKOPIX_PROVIDER=${answers.provider}`,
|
|
126
|
+
'',
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
if (answers.provider === 'gemini') {
|
|
130
|
+
lines.push(`GEMINI_API_KEY=${answers.geminiKey}`);
|
|
131
|
+
} else if (answers.provider === 'ollama') {
|
|
132
|
+
lines.push(`OLLAMA_BASE_URL=${answers.ollamaUrl}`);
|
|
133
|
+
lines.push(`OLLAMA_MODEL=${answers.ollamaModel}`);
|
|
134
|
+
} else if (answers.provider === 'openai') {
|
|
135
|
+
lines.push(`OPENAI_API_KEY=${answers.openaiKey}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (answers.setupJira) {
|
|
139
|
+
lines.push('', '# Jira');
|
|
140
|
+
lines.push(`JIRA_BASE_URL=${answers.jiraUrl}`);
|
|
141
|
+
lines.push(`JIRA_EMAIL=${answers.jiraEmail}`);
|
|
142
|
+
lines.push(`JIRA_API_TOKEN=${answers.jiraToken}`);
|
|
143
|
+
lines.push(`JIRA_PROJECT_KEY=${answers.jiraProject}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (answers.setupLinear) {
|
|
147
|
+
lines.push('', '# Linear');
|
|
148
|
+
lines.push(`LINEAR_API_KEY=${answers.linearKey}`);
|
|
149
|
+
lines.push(`LINEAR_TEAM_ID=${answers.linearTeam}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (answers.setupGithub) {
|
|
153
|
+
lines.push('', '# GitHub');
|
|
154
|
+
lines.push(`GITHUB_REPO=${answers.githubRepo}`);
|
|
155
|
+
lines.push(`GITHUB_TOKEN=${answers.githubToken}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const envPath = path.resolve(process.cwd(), '.skopix.env');
|
|
159
|
+
await fs.writeFile(envPath, lines.join('\n') + '\n');
|
|
160
|
+
|
|
161
|
+
// Also create a sample credentials file
|
|
162
|
+
const credsSample = [
|
|
163
|
+
'# Skopix credentials file',
|
|
164
|
+
'# Use with: skopix run --credentials credentials.yaml',
|
|
165
|
+
'# Values are passed to the AI agent for form filling',
|
|
166
|
+
'',
|
|
167
|
+
'credentials:',
|
|
168
|
+
' - label: "Main test account"',
|
|
169
|
+
' fields:',
|
|
170
|
+
' email: "testuser@example.com"',
|
|
171
|
+
' password: "yourpassword"',
|
|
172
|
+
' username: "testuser"',
|
|
173
|
+
'',
|
|
174
|
+
' - label: "Admin account"',
|
|
175
|
+
' fields:',
|
|
176
|
+
' email: "admin@example.com"',
|
|
177
|
+
' password: "adminpassword"',
|
|
178
|
+
].join('\n');
|
|
179
|
+
|
|
180
|
+
const credsPath = path.resolve(process.cwd(), 'credentials.yaml');
|
|
181
|
+
if (!await fs.pathExists(credsPath)) {
|
|
182
|
+
await fs.writeFile(credsPath, credsSample);
|
|
183
|
+
console.log(chalk.dim(`\n Sample credentials file created → credentials.yaml`));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log(chalk.green.bold('\n ✓ Configuration saved → .skopix.env'));
|
|
187
|
+
console.log(chalk.dim('\n Add .skopix.env to your .gitignore to keep secrets safe!\n'));
|
|
188
|
+
console.log(chalk.white(' You\'re ready to go. Try:'));
|
|
189
|
+
console.log(chalk.cyan(' skopix run --url https://yoursite.com --goal "test the login flow"\n'));
|
|
190
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import open from 'open';
|
|
5
|
+
|
|
6
|
+
export async function reportCommand(options) {
|
|
7
|
+
const dir = path.resolve(options.dir);
|
|
8
|
+
|
|
9
|
+
if (!await fs.pathExists(dir)) {
|
|
10
|
+
console.log(chalk.red(`\n No reports found at: ${dir}\n`));
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let sessions = await fs.readdir(dir);
|
|
15
|
+
sessions = sessions.filter(s => !s.startsWith('.'));
|
|
16
|
+
|
|
17
|
+
if (sessions.length === 0) {
|
|
18
|
+
console.log(chalk.yellow('\n No sessions found. Run a test first!\n'));
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Sort by actual modification time (most recent first)
|
|
23
|
+
const withStats = await Promise.all(
|
|
24
|
+
sessions.map(async (s) => {
|
|
25
|
+
const stat = await fs.stat(path.join(dir, s));
|
|
26
|
+
return { name: s, mtime: stat.mtime };
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
const sorted = withStats.sort((a, b) => b.mtime - a.mtime);
|
|
30
|
+
const latest = sorted[0].name;
|
|
31
|
+
const reportPath = path.join(dir, latest, 'report.html');
|
|
32
|
+
|
|
33
|
+
if (!await fs.pathExists(reportPath)) {
|
|
34
|
+
console.log(chalk.red(`\n Report not found: ${reportPath}\n`));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(chalk.green(`\n Opening report: ${reportPath}\n`));
|
|
39
|
+
console.log(chalk.dim(` Session: ${latest}\n`));
|
|
40
|
+
await open(reportPath);
|
|
41
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
6
|
+
import { BrowserAgent } from '../../core/browser.js';
|
|
7
|
+
import { LLMRouter } from '../../core/llm.js';
|
|
8
|
+
import { ReportGenerator } from '../../core/reporter.js';
|
|
9
|
+
import { IssueTracker } from '../../core/tracker.js';
|
|
10
|
+
import { loadCredentials } from '../../core/credentials.js';
|
|
11
|
+
import { formatStep, formatIssue, printSummary } from '../ui.js';
|
|
12
|
+
|
|
13
|
+
export async function runCommand(options) {
|
|
14
|
+
const sessionId = uuidv4().slice(0, 8);
|
|
15
|
+
const startTime = Date.now();
|
|
16
|
+
|
|
17
|
+
console.log(chalk.cyan('━'.repeat(60)));
|
|
18
|
+
console.log(chalk.white.bold(` Session: `) + chalk.yellow(sessionId));
|
|
19
|
+
console.log(chalk.white.bold(` Target: `) + chalk.green(options.url));
|
|
20
|
+
console.log(chalk.white.bold(` Goal: `) + chalk.white(options.goal));
|
|
21
|
+
console.log(chalk.white.bold(` Provider:`) + chalk.magenta(options.provider));
|
|
22
|
+
console.log(chalk.cyan('━'.repeat(60)));
|
|
23
|
+
console.log();
|
|
24
|
+
|
|
25
|
+
// Load credentials if provided
|
|
26
|
+
let credentials = {};
|
|
27
|
+
if (options.credentials) {
|
|
28
|
+
const spinner = ora('Loading credentials...').start();
|
|
29
|
+
try {
|
|
30
|
+
credentials = await loadCredentials(options.credentials);
|
|
31
|
+
spinner.succeed(chalk.green(`Credentials loaded (${Object.keys(credentials).length} entries)`));
|
|
32
|
+
} catch (err) {
|
|
33
|
+
spinner.fail(chalk.red(`Failed to load credentials: ${err.message}`));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Set up output directory
|
|
39
|
+
const outputDir = path.resolve(options.output, sessionId);
|
|
40
|
+
await fs.ensureDir(outputDir);
|
|
41
|
+
|
|
42
|
+
// Initialise LLM
|
|
43
|
+
const spinner = ora('Initialising AI agent...').start();
|
|
44
|
+
let llm;
|
|
45
|
+
try {
|
|
46
|
+
llm = new LLMRouter(options.provider, options.model);
|
|
47
|
+
await llm.verify();
|
|
48
|
+
spinner.succeed(chalk.green(`AI agent ready (${llm.modelName})`));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
spinner.fail(chalk.red(`LLM init failed: ${err.message}`));
|
|
51
|
+
console.log(chalk.yellow('\n Hint: Run `skopix init` to configure your API keys.\n'));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Launch browser
|
|
56
|
+
const browserSpinner = ora('Launching browser...').start();
|
|
57
|
+
const agent = new BrowserAgent({
|
|
58
|
+
headless: options.headless,
|
|
59
|
+
videoDir: options.video !== false ? outputDir : null,
|
|
60
|
+
sessionId,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await agent.launch();
|
|
65
|
+
browserSpinner.succeed(chalk.green('Browser launched'));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
browserSpinner.fail(chalk.red(`Browser failed: ${err.message}`));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Navigate to URL
|
|
72
|
+
const navSpinner = ora(`Navigating to ${options.url}...`).start();
|
|
73
|
+
try {
|
|
74
|
+
await agent.goto(options.url);
|
|
75
|
+
navSpinner.succeed(chalk.green(`Loaded: ${options.url}`));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
navSpinner.fail(chalk.red(`Navigation failed: ${err.message}`));
|
|
78
|
+
await agent.close();
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log();
|
|
83
|
+
console.log(chalk.cyan.bold(' ◆ Agent loop starting\n'));
|
|
84
|
+
|
|
85
|
+
const steps = [];
|
|
86
|
+
const issues = [];
|
|
87
|
+
const maxSteps = parseInt(options.maxSteps);
|
|
88
|
+
let goalAchieved = false;
|
|
89
|
+
let stuck = false;
|
|
90
|
+
let previousDOMHash = null;
|
|
91
|
+
let stuckCount = 0;
|
|
92
|
+
|
|
93
|
+
// ─── MAIN AGENT LOOP ────────────────────────────────────────────────────────
|
|
94
|
+
for (let step = 1; step <= maxSteps; step++) {
|
|
95
|
+
const stepSpinner = ora({
|
|
96
|
+
text: chalk.dim(`Step ${step}/${maxSteps} — extracting page state...`),
|
|
97
|
+
color: 'cyan',
|
|
98
|
+
}).start();
|
|
99
|
+
|
|
100
|
+
let domSnapshot, screenshot;
|
|
101
|
+
try {
|
|
102
|
+
domSnapshot = await agent.extractDOM();
|
|
103
|
+
screenshot = await agent.screenshot(path.join(outputDir, `step-${step}.png`));
|
|
104
|
+
} catch (err) {
|
|
105
|
+
stepSpinner.fail(chalk.red(`DOM extraction failed: ${err.message}`));
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Detect if we're stuck - allow 5 unchanged DOM snapshots before giving up
|
|
110
|
+
// Skip stuck detection if last action was OBSERVE (no DOM change expected)
|
|
111
|
+
const lastAction = steps.length > 0 ? steps[steps.length - 1].action : null;
|
|
112
|
+
const currentHash = simpleHash(domSnapshot.text);
|
|
113
|
+
if (currentHash === previousDOMHash && lastAction !== 'OBSERVE') {
|
|
114
|
+
stuckCount++;
|
|
115
|
+
if (stuckCount >= 5) {
|
|
116
|
+
stepSpinner.warn(chalk.yellow('Agent appears stuck — no DOM changes detected after 5 attempts'));
|
|
117
|
+
stuck = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
stuckCount = 0;
|
|
122
|
+
}
|
|
123
|
+
previousDOMHash = currentHash;
|
|
124
|
+
|
|
125
|
+
// Ask LLM what to do next
|
|
126
|
+
stepSpinner.text = chalk.dim(`Step ${step}/${maxSteps} — reasoning...`);
|
|
127
|
+
|
|
128
|
+
let decision;
|
|
129
|
+
try {
|
|
130
|
+
decision = await llm.decide({
|
|
131
|
+
goal: options.goal,
|
|
132
|
+
url: options.url,
|
|
133
|
+
currentUrl: await agent.currentUrl(),
|
|
134
|
+
domSnapshot: domSnapshot.text,
|
|
135
|
+
stepNumber: step,
|
|
136
|
+
previousSteps: steps.slice(-5), // Last 5 for context
|
|
137
|
+
credentials,
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
stepSpinner.fail(chalk.red(`LLM reasoning failed: ${err.message}`));
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Execute the action
|
|
145
|
+
stepSpinner.text = chalk.dim(`Step ${step}/${maxSteps} — executing: ${decision.action}...`);
|
|
146
|
+
|
|
147
|
+
let actionResult = { success: false, error: null };
|
|
148
|
+
try {
|
|
149
|
+
actionResult = await agent.executeAction(decision);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
actionResult = { success: false, error: err.message };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Record the step
|
|
155
|
+
const stepRecord = {
|
|
156
|
+
step,
|
|
157
|
+
url: await agent.currentUrl(),
|
|
158
|
+
action: decision.action,
|
|
159
|
+
target: decision.target,
|
|
160
|
+
value: decision.value ? '***' : undefined,
|
|
161
|
+
reasoning: decision.reasoning,
|
|
162
|
+
observation: decision.observation,
|
|
163
|
+
confidence: decision.confidence,
|
|
164
|
+
issues: decision.issues || [],
|
|
165
|
+
success: actionResult.success,
|
|
166
|
+
error: actionResult.error,
|
|
167
|
+
screenshot: `step-${step}.png`,
|
|
168
|
+
batchResults: actionResult.batchResults || null,
|
|
169
|
+
batchSize: decision.actions ? decision.actions.length : null,
|
|
170
|
+
actions: decision.actions || null,
|
|
171
|
+
timestamp: new Date().toISOString(),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
steps.push(stepRecord);
|
|
175
|
+
|
|
176
|
+
// Collect issues - aggressively deduplicate similar ones across steps
|
|
177
|
+
if (decision.issues && decision.issues.length > 0) {
|
|
178
|
+
for (const issue of decision.issues) {
|
|
179
|
+
// Build a stricter fingerprint that catches semantic duplicates
|
|
180
|
+
// - Strip common variation words ("with", "during", filler), normalise whitespace
|
|
181
|
+
// - Use first 5 meaningful words rather than first 30 chars
|
|
182
|
+
const normaliseTitle = (t) => {
|
|
183
|
+
return (t || '')
|
|
184
|
+
.toLowerCase()
|
|
185
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
186
|
+
.replace(/\b(the|a|an|with|of|on|in|for|to|and|or|when|during|at|that)\b/g, ' ')
|
|
187
|
+
.replace(/\s+/g, ' ')
|
|
188
|
+
.trim()
|
|
189
|
+
.split(' ')
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.slice(0, 5)
|
|
192
|
+
.join(' ');
|
|
193
|
+
};
|
|
194
|
+
const fingerprint = normaliseTitle(issue.title) + '|' + (issue.type || '');
|
|
195
|
+
const isDuplicate = issues.some(existing => {
|
|
196
|
+
const existingFp = normaliseTitle(existing.title) + '|' + (existing.type || '');
|
|
197
|
+
// Either fingerprints match OR one title is a substring of the other
|
|
198
|
+
if (existingFp === fingerprint) return true;
|
|
199
|
+
const a = (existing.title || '').toLowerCase();
|
|
200
|
+
const b = (issue.title || '').toLowerCase();
|
|
201
|
+
// Detect cases like "Login failed with invalid credentials" vs "Login failed with incorrect credentials"
|
|
202
|
+
// by checking if they share most meaningful words
|
|
203
|
+
const aWords = new Set(normaliseTitle(a).split(' '));
|
|
204
|
+
const bWords = new Set(normaliseTitle(b).split(' '));
|
|
205
|
+
if (aWords.size === 0 || bWords.size === 0) return false;
|
|
206
|
+
const overlap = [...aWords].filter(w => bWords.has(w)).length;
|
|
207
|
+
const minSize = Math.min(aWords.size, bWords.size);
|
|
208
|
+
// 60%+ word overlap = same issue
|
|
209
|
+
return minSize > 0 && (overlap / minSize) >= 0.6;
|
|
210
|
+
});
|
|
211
|
+
if (!isDuplicate) {
|
|
212
|
+
issues.push({
|
|
213
|
+
...issue,
|
|
214
|
+
step,
|
|
215
|
+
url: stepRecord.url,
|
|
216
|
+
screenshot: `step-${step}.png`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Print step output
|
|
223
|
+
stepSpinner.stop();
|
|
224
|
+
formatStep(stepRecord, step, maxSteps);
|
|
225
|
+
|
|
226
|
+
// Check if goal is complete
|
|
227
|
+
if (decision.goalAchieved) {
|
|
228
|
+
console.log();
|
|
229
|
+
console.log(chalk.green.bold(' ✓ Goal achieved!'));
|
|
230
|
+
goalAchieved = true;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Auto-detect goal completion based on URL/page changes for common goals
|
|
235
|
+
// Heuristic auto-detection of login success/failure was removed.
|
|
236
|
+
// It would force goalAchieved=true on simple login-goal keyword matches,
|
|
237
|
+
// causing multi-step goals like "log in AND open Dates category" to false-pass
|
|
238
|
+
// after just typing into the form. The LLM is now responsible for setting
|
|
239
|
+
// goalAchieved based on the full goal, with explicit prompt guidance on
|
|
240
|
+
// reading the goal literally and only marking achieved when the actual
|
|
241
|
+
// required outcome was observed.
|
|
242
|
+
|
|
243
|
+
if (decision.action === 'STOP') {
|
|
244
|
+
console.log();
|
|
245
|
+
console.log(chalk.yellow(' ⚠ Agent decided to stop: ') + chalk.white(decision.reasoning));
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Small delay between steps
|
|
250
|
+
await sleep(150);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log();
|
|
254
|
+
console.log(chalk.cyan('━'.repeat(60)));
|
|
255
|
+
|
|
256
|
+
// Stop recording
|
|
257
|
+
const closeSpinner = ora('Finalising session...').start();
|
|
258
|
+
let videoPath = null;
|
|
259
|
+
try {
|
|
260
|
+
videoPath = await agent.close();
|
|
261
|
+
closeSpinner.succeed(chalk.green('Browser closed'));
|
|
262
|
+
} catch (err) {
|
|
263
|
+
closeSpinner.warn(chalk.yellow(`Browser close warning: ${err.message}`));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Test pass/fail is based purely on whether the goal was achieved.
|
|
267
|
+
// Issues found during the test are reported separately - they're observations the agent
|
|
268
|
+
// noticed but they do not influence the pass/fail status unless directly related to the goal.
|
|
269
|
+
const actuallyPassed = goalAchieved;
|
|
270
|
+
const failReason = null;
|
|
271
|
+
|
|
272
|
+
// Generate report
|
|
273
|
+
const reportSpinner = ora('Generating report...').start();
|
|
274
|
+
const reporter = new ReportGenerator(outputDir, sessionId);
|
|
275
|
+
let reportPath;
|
|
276
|
+
try {
|
|
277
|
+
reportPath = await reporter.generate({
|
|
278
|
+
sessionId,
|
|
279
|
+
url: options.url,
|
|
280
|
+
goal: options.goal,
|
|
281
|
+
steps,
|
|
282
|
+
issues,
|
|
283
|
+
goalAchieved: actuallyPassed,
|
|
284
|
+
goalActuallyAchieved: goalAchieved,
|
|
285
|
+
failReason,
|
|
286
|
+
stuck,
|
|
287
|
+
videoPath,
|
|
288
|
+
duration: Date.now() - startTime,
|
|
289
|
+
provider: options.provider,
|
|
290
|
+
model: llm.modelName,
|
|
291
|
+
});
|
|
292
|
+
reportSpinner.succeed(chalk.green(`Report saved → ${reportPath}`));
|
|
293
|
+
} catch (err) {
|
|
294
|
+
reportSpinner.fail(chalk.red(`Report generation failed: ${err.message}`));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Push to issue tracker if requested
|
|
298
|
+
if ((options.jira || options.linear || options.github) && issues.length > 0) {
|
|
299
|
+
const trackerSpinner = ora('Pushing issues to tracker...').start();
|
|
300
|
+
try {
|
|
301
|
+
const tracker = new IssueTracker({ jira: options.jira, linear: options.linear, github: options.github });
|
|
302
|
+
const created = await tracker.pushIssues(issues, { url: options.url, goal: options.goal, sessionId });
|
|
303
|
+
const newCount = created.filter(c => c.action === 'created').length;
|
|
304
|
+
const commentedCount = created.filter(c => c.action === 'commented').length;
|
|
305
|
+
const semanticCount = created.filter(c => c.action === 'commented' && c.matchedBy === 'semantic').length;
|
|
306
|
+
let summary;
|
|
307
|
+
if (newCount > 0 && commentedCount > 0) {
|
|
308
|
+
summary = `${newCount} new issue(s), ${commentedCount} existing issue(s) updated`;
|
|
309
|
+
if (semanticCount > 0) summary += ` (${semanticCount} matched semantically)`;
|
|
310
|
+
} else if (commentedCount > 0) {
|
|
311
|
+
summary = `${commentedCount} existing issue(s) updated (already known)`;
|
|
312
|
+
if (semanticCount > 0) summary += ` — ${semanticCount} matched semantically`;
|
|
313
|
+
} else {
|
|
314
|
+
summary = `${newCount} issue(s) created in tracker`;
|
|
315
|
+
}
|
|
316
|
+
trackerSpinner.succeed(chalk.green(summary));
|
|
317
|
+
} catch (err) {
|
|
318
|
+
trackerSpinner.fail(chalk.red(`Tracker error: ${err.message}`));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Print summary
|
|
323
|
+
printSummary({
|
|
324
|
+
sessionId,
|
|
325
|
+
steps: steps.length,
|
|
326
|
+
issues: issues.length,
|
|
327
|
+
goalAchieved: actuallyPassed,
|
|
328
|
+
stuck,
|
|
329
|
+
duration: Date.now() - startTime,
|
|
330
|
+
reportPath,
|
|
331
|
+
videoPath,
|
|
332
|
+
failReason,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
process.exit(actuallyPassed ? 0 : 1);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function simpleHash(str) {
|
|
339
|
+
let hash = 0;
|
|
340
|
+
for (let i = 0; i < Math.min(str.length, 1000); i++) {
|
|
341
|
+
const char = str.charCodeAt(i);
|
|
342
|
+
hash = (hash << 5) - hash + char;
|
|
343
|
+
hash = hash & hash;
|
|
344
|
+
}
|
|
345
|
+
return hash;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function sleep(ms) {
|
|
349
|
+
return new Promise(r => setTimeout(r, ms));
|
|
350
|
+
}
|
package/cli/index.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import boxen from 'boxen';
|
|
5
|
+
import { runCommand } from './commands/run.js';
|
|
6
|
+
import { initCommand } from './commands/init.js';
|
|
7
|
+
import { reportCommand } from './commands/report.js';
|
|
8
|
+
import { configCommand } from './commands/config.js';
|
|
9
|
+
import { dashboardCommand } from './commands/dashboard.js';
|
|
10
|
+
import { agentCommand } from './commands/agent.js';
|
|
11
|
+
|
|
12
|
+
const banner = chalk.cyan(`
|
|
13
|
+
███████╗██╗ ██╗ ██████╗ ██████╗ ██╗██╗ ██╗
|
|
14
|
+
██╔════╝██║ ██╔╝██╔═══██╗██╔══██╗██║╚██╗██╔╝
|
|
15
|
+
███████╗█████╔╝ ██║ ██║██████╔╝██║ ╚███╔╝
|
|
16
|
+
╚════██║██╔═██╗ ██║ ██║██╔═══╝ ██║ ██╔██╗
|
|
17
|
+
███████║██║ ██╗╚██████╔╝██║ ██║██╔╝ ██╗
|
|
18
|
+
╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝
|
|
19
|
+
`);
|
|
20
|
+
|
|
21
|
+
console.log(banner);
|
|
22
|
+
console.log(chalk.dim(' AI-powered QA agent. Tests your app like a human would.\n'));
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.name('skopix')
|
|
26
|
+
.description('AI-powered QA testing agent')
|
|
27
|
+
.version('1.0.0');
|
|
28
|
+
|
|
29
|
+
program
|
|
30
|
+
.command('run')
|
|
31
|
+
.description('Run a QA test session on a URL')
|
|
32
|
+
.requiredOption('-u, --url <url>', 'Target URL to test')
|
|
33
|
+
.requiredOption('-g, --goal <goal>', 'Testing goal (e.g. "complete the checkout flow")')
|
|
34
|
+
.option('-c, --credentials <file>', 'Path to credentials YAML file')
|
|
35
|
+
.option('-o, --output <dir>', 'Output directory for reports', './skopix-reports')
|
|
36
|
+
.option('-m, --max-steps <number>', 'Maximum steps the agent will take', '20')
|
|
37
|
+
.option('--headless', 'Run browser in headless mode', false)
|
|
38
|
+
.option('--no-video', 'Disable video recording')
|
|
39
|
+
.option('--provider <provider>', 'LLM provider: gemini | ollama', 'gemini')
|
|
40
|
+
.option('--model <model>', 'Model name override')
|
|
41
|
+
.option('--jira', 'Push issues to Jira (requires JIRA_* env vars)')
|
|
42
|
+
.option('--linear', 'Push issues to Linear (requires LINEAR_API_KEY env var)')
|
|
43
|
+
.option('--github', 'Push issues to GitHub Issues (requires GITHUB_* env vars)')
|
|
44
|
+
.option('--test-name <name>', 'Name of the test (for context in created issues)')
|
|
45
|
+
.option('--suite-name <name>', 'Name of the suite this test belongs to (for context)')
|
|
46
|
+
.action(runCommand);
|
|
47
|
+
|
|
48
|
+
program
|
|
49
|
+
.command('init')
|
|
50
|
+
.description('Initialise Skopix config in the current directory')
|
|
51
|
+
.action(initCommand);
|
|
52
|
+
|
|
53
|
+
program
|
|
54
|
+
.command('report')
|
|
55
|
+
.description('Open the latest report in your browser')
|
|
56
|
+
.option('-d, --dir <dir>', 'Reports directory', './skopix-reports')
|
|
57
|
+
.action(reportCommand);
|
|
58
|
+
|
|
59
|
+
program
|
|
60
|
+
.command('config')
|
|
61
|
+
.description('View or set configuration values')
|
|
62
|
+
.option('--set <key=value>', 'Set a config value')
|
|
63
|
+
.option('--get <key>', 'Get a config value')
|
|
64
|
+
.option('--list', 'List all config values')
|
|
65
|
+
.action(configCommand);
|
|
66
|
+
|
|
67
|
+
program
|
|
68
|
+
.command('dashboard')
|
|
69
|
+
.description('Launch the web dashboard')
|
|
70
|
+
.option('-p, --port <port>', 'Port to run the server on', '9000')
|
|
71
|
+
.option('-d, --dir <dir>', 'Reports directory', './skopix-reports')
|
|
72
|
+
.option('-h, --host <host>', 'Host to bind to (default 127.0.0.1; use 0.0.0.0 for team mode)')
|
|
73
|
+
.option('--team', 'Enable multi-user team mode (requires SQLite)')
|
|
74
|
+
.option('--no-open', 'Do not auto-open the browser')
|
|
75
|
+
.action(dashboardCommand);
|
|
76
|
+
|
|
77
|
+
program
|
|
78
|
+
.command('agent')
|
|
79
|
+
.description('Connect this machine as an agent to a shared Skopix server')
|
|
80
|
+
.requiredOption('-s, --server <url>', 'Skopix server URL (e.g. http://192.168.1.45:9000)')
|
|
81
|
+
.requiredOption('-k, --key <key>', 'Secret key (same SKOPIX_SECRET_KEY as the server)')
|
|
82
|
+
.option('-n, --name <name>', 'Agent display name (defaults to hostname)')
|
|
83
|
+
.action(agentCommand);
|
|
84
|
+
|
|
85
|
+
program.parse();
|