@thecolonylab/hivemind 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/LICENSE +21 -0
- package/bin/hivemind.js +2 -0
- package/dist/browser/session.js +21 -0
- package/dist/cli.js +78 -0
- package/dist/commands/apply.js +33 -0
- package/dist/commands/coverage.js +98 -0
- package/dist/commands/coverageLocal.js +60 -0
- package/dist/commands/create.js +40 -0
- package/dist/commands/dashboard.js +12 -0
- package/dist/commands/explore.js +34 -0
- package/dist/commands/init.js +50 -0
- package/dist/commands/login.js +20 -0
- package/dist/commands/remoteExecutor.js +188 -0
- package/dist/commands/report.js +44 -0
- package/dist/commands/run.js +171 -0
- package/dist/commands/runLocal.js +99 -0
- package/dist/config.js +37 -0
- package/dist/credentials.js +24 -0
- package/dist/report/artifacts.js +48 -0
- package/dist/report/html.js +299 -0
- package/dist/report/prComment.js +50 -0
- package/dist/report/terminal.js +21 -0
- package/dist/serverClient.js +57 -0
- package/dist/testFile/discover.js +19 -0
- package/dist/testFile/listExisting.js +24 -0
- package/dist/testFile/loadAllTests.js +31 -0
- package/dist/testFile/parse.js +91 -0
- package/dist/testFile/resolve.js +65 -0
- package/dist/testFile/select.js +39 -0
- package/dist/testFile/types.js +1 -0
- package/dist/testFile/write.js +37 -0
- package/dist/types.js +4 -0
- package/package.json +35 -0
- package/skill/AGENTS.md.template +17 -0
- package/skill/hivemind-cli/SKILL.md +142 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Forager — runs on your machine, executes exactly what the Hivemind brain tells it to, one
|
|
3
|
+
* named tool call at a time. Same `execute(name, input)` shape used in-process on the server for
|
|
4
|
+
* cloud runs; the only difference is these calls arrive over a socket instead of a direct
|
|
5
|
+
* function call. `selector` args are real CSS/Playwright selector strings computed fresh by
|
|
6
|
+
* snapshot() — this class never invents, matches, or remembers anything, it just runs
|
|
7
|
+
* page.locator(selector) directly, same as any plain Playwright script.
|
|
8
|
+
*/
|
|
9
|
+
export class Forager {
|
|
10
|
+
page;
|
|
11
|
+
runStart;
|
|
12
|
+
pageStack = [];
|
|
13
|
+
screenshots = [];
|
|
14
|
+
constructor(page, runStart) {
|
|
15
|
+
this.page = page;
|
|
16
|
+
this.runStart = runStart;
|
|
17
|
+
}
|
|
18
|
+
getScreenshots() {
|
|
19
|
+
return this.screenshots;
|
|
20
|
+
}
|
|
21
|
+
async execute(name, input) {
|
|
22
|
+
try {
|
|
23
|
+
switch (name) {
|
|
24
|
+
case 'browser_navigate': {
|
|
25
|
+
const pagesBefore = new Set(this.page.context().pages());
|
|
26
|
+
await this.page.goto(input.url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
|
27
|
+
const newTabUrl = await this.followNewTabIfOpened(pagesBefore, true);
|
|
28
|
+
return { ok: true, message: newTabUrl ? `Navigated — new tab opened: ${newTabUrl}` : `Navigated to ${input.url}` };
|
|
29
|
+
}
|
|
30
|
+
case 'browser_snapshot': {
|
|
31
|
+
const data = await this.buildSnapshot();
|
|
32
|
+
return { ok: true, message: 'Snapshot captured', data };
|
|
33
|
+
}
|
|
34
|
+
case 'browser_click': {
|
|
35
|
+
const pagesBefore = new Set(this.page.context().pages());
|
|
36
|
+
await this.page.locator(input.selector).first().click({ timeout: 10_000 });
|
|
37
|
+
const newTabUrl = await this.followNewTabIfOpened(pagesBefore);
|
|
38
|
+
return { ok: true, message: newTabUrl ? `Clicked — new tab opened: ${newTabUrl}` : `Clicked ${input.selector}` };
|
|
39
|
+
}
|
|
40
|
+
case 'browser_type': {
|
|
41
|
+
await this.page.locator(input.selector).first().fill(input.text ?? '', { timeout: 10_000 });
|
|
42
|
+
return { ok: true, message: `Typed into ${input.selector}` };
|
|
43
|
+
}
|
|
44
|
+
case 'browser_key_press': {
|
|
45
|
+
await this.page.keyboard.press(input.key);
|
|
46
|
+
return { ok: true, message: `Pressed ${input.key}` };
|
|
47
|
+
}
|
|
48
|
+
case 'browser_hover': {
|
|
49
|
+
await this.page.locator(input.selector).first().hover({ timeout: 10_000 });
|
|
50
|
+
return { ok: true, message: `Hovered ${input.selector}` };
|
|
51
|
+
}
|
|
52
|
+
case 'browser_select': {
|
|
53
|
+
await this.page.locator(input.selector).first().selectOption({ label: input.option });
|
|
54
|
+
return { ok: true, message: `Selected "${input.option}" in ${input.selector}` };
|
|
55
|
+
}
|
|
56
|
+
case 'browser_wait_for': {
|
|
57
|
+
if (input.text) {
|
|
58
|
+
await this.page.getByText(input.text, { exact: false }).first().waitFor({ timeout: input.timeoutMs ?? 5000 });
|
|
59
|
+
return { ok: true, message: `Text "${input.text}" appeared` };
|
|
60
|
+
}
|
|
61
|
+
await this.page.waitForTimeout(input.timeoutMs ?? 1000);
|
|
62
|
+
return { ok: true, message: 'Waited' };
|
|
63
|
+
}
|
|
64
|
+
case 'browser_get_text': {
|
|
65
|
+
const text = input.selector
|
|
66
|
+
? await this.page.locator(input.selector).first().innerText().catch(() => '')
|
|
67
|
+
: await this.page.innerText('body').catch(() => '');
|
|
68
|
+
return { ok: true, message: 'Text retrieved', data: (text || '').slice(0, 4000) };
|
|
69
|
+
}
|
|
70
|
+
case 'browser_get_url': {
|
|
71
|
+
return { ok: true, message: this.page.url() };
|
|
72
|
+
}
|
|
73
|
+
case 'browser_scroll': {
|
|
74
|
+
await this.page.mouse.wheel(0, input.direction === 'up' ? -800 : 800);
|
|
75
|
+
return { ok: true, message: `Scrolled ${input.direction}` };
|
|
76
|
+
}
|
|
77
|
+
case 'browser_screenshot': {
|
|
78
|
+
const meta = await this.takeScreenshot(input.label);
|
|
79
|
+
return { ok: true, message: 'Screenshot captured', data: meta };
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
return { ok: false, message: `Unknown tool: ${name}` };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return { ok: false, message: `Error running ${name}: ${err?.message || String(err)}` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async takeScreenshot(label) {
|
|
90
|
+
const buffer = await this.page.screenshot();
|
|
91
|
+
const meta = { data: buffer.toString('base64'), label, elapsedMs: Date.now() - this.runStart };
|
|
92
|
+
this.screenshots.push(meta);
|
|
93
|
+
return meta;
|
|
94
|
+
}
|
|
95
|
+
/** Computes a real, reusable CSS selector for every visible interactive element — id first,
|
|
96
|
+
* then aria-label, then placeholder/name, then visible text. Plain deterministic string-
|
|
97
|
+
* building (the same rules anyone writing a Playwright script by hand would use), not
|
|
98
|
+
* matching or judgment — the string handed back is valid on its own, nothing to remember. */
|
|
99
|
+
async buildSnapshot() {
|
|
100
|
+
const url = this.page.url();
|
|
101
|
+
const title = await this.page.title().catch(() => '');
|
|
102
|
+
const lines = await this.page.evaluate(() => {
|
|
103
|
+
const out = [];
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const selectors = 'a, button, input, textarea, select, [role="button"], [role="link"], h1, h2, h3';
|
|
106
|
+
document.querySelectorAll(selectors).forEach((el) => {
|
|
107
|
+
if (seen.has(el))
|
|
108
|
+
return;
|
|
109
|
+
seen.add(el);
|
|
110
|
+
const rect = el.getBoundingClientRect();
|
|
111
|
+
if (rect.width === 0 || rect.height === 0)
|
|
112
|
+
return;
|
|
113
|
+
const tag = el.tagName.toLowerCase();
|
|
114
|
+
const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
|
|
115
|
+
const ariaLabel = el.getAttribute('aria-label') || '';
|
|
116
|
+
const placeholder = el.placeholder || '';
|
|
117
|
+
const name = el.name || '';
|
|
118
|
+
const id = el.id;
|
|
119
|
+
let selector = '';
|
|
120
|
+
if (id)
|
|
121
|
+
selector = `#${CSS.escape(id)}`;
|
|
122
|
+
else if (ariaLabel)
|
|
123
|
+
selector = `[aria-label="${ariaLabel}"]`;
|
|
124
|
+
else if (placeholder)
|
|
125
|
+
selector = `[placeholder="${placeholder}"]`;
|
|
126
|
+
else if (name)
|
|
127
|
+
selector = `${tag}[name="${name}"]`;
|
|
128
|
+
else if (text)
|
|
129
|
+
selector = `${tag}:has-text("${text.slice(0, 40)}")`;
|
|
130
|
+
else
|
|
131
|
+
return;
|
|
132
|
+
const label = ariaLabel || placeholder || text || name;
|
|
133
|
+
if (!label)
|
|
134
|
+
return;
|
|
135
|
+
out.push(`${selector} — ${tag}: ${label}`);
|
|
136
|
+
});
|
|
137
|
+
return out.slice(0, 150);
|
|
138
|
+
});
|
|
139
|
+
return `URL: ${url}\nTitle: ${title}\nVisible elements (selector — description):\n${lines.join('\n')}`;
|
|
140
|
+
}
|
|
141
|
+
/** Same new-tab/popup handling proven in agents/tools/executor.ts — a click/navigate can open a
|
|
142
|
+
* new tab; without following it, subsequent snapshots would keep describing the old, stale tab. */
|
|
143
|
+
async followNewTabIfOpened(pagesBefore, extendedWait = false) {
|
|
144
|
+
const context = this.page.context();
|
|
145
|
+
const waitMs = extendedWait ? 4000 : 600;
|
|
146
|
+
const alreadyOpen = context.pages().find((p) => !pagesBefore.has(p) && !p.isClosed());
|
|
147
|
+
if (alreadyOpen)
|
|
148
|
+
return this.switchToNewPage(alreadyOpen);
|
|
149
|
+
let capturedPage = null;
|
|
150
|
+
let resolveCapture;
|
|
151
|
+
const capturePromise = new Promise((r) => {
|
|
152
|
+
resolveCapture = r;
|
|
153
|
+
});
|
|
154
|
+
const onPage = (p) => {
|
|
155
|
+
if (!pagesBefore.has(p) && !capturedPage) {
|
|
156
|
+
capturedPage = p;
|
|
157
|
+
resolveCapture();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
context.on('page', onPage);
|
|
161
|
+
await Promise.race([capturePromise, new Promise((r) => setTimeout(r, waitMs))]);
|
|
162
|
+
context.off('page', onPage);
|
|
163
|
+
const cp = capturedPage;
|
|
164
|
+
if (!cp || cp.isClosed())
|
|
165
|
+
return null;
|
|
166
|
+
return this.switchToNewPage(cp);
|
|
167
|
+
}
|
|
168
|
+
async switchToNewPage(newPage) {
|
|
169
|
+
const prevPage = this.page;
|
|
170
|
+
await newPage.waitForLoadState('domcontentloaded', { timeout: 10_000 }).catch(() => { });
|
|
171
|
+
this.pageStack.push(prevPage);
|
|
172
|
+
this.page = newPage;
|
|
173
|
+
newPage.on('close', () => {
|
|
174
|
+
if (this.page === newPage) {
|
|
175
|
+
const fallback = this.pageStack.pop();
|
|
176
|
+
if (fallback && !fallback.isClosed()) {
|
|
177
|
+
this.page = fallback;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
const open = this.page.context().pages().find((p) => !p.isClosed());
|
|
181
|
+
if (open)
|
|
182
|
+
this.page = open;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return newPage.url();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
function serverUrl() {
|
|
5
|
+
return process.env.HIVEMIND_SERVER_URL || 'https://app.thecolonylab.com';
|
|
6
|
+
}
|
|
7
|
+
function open(url) {
|
|
8
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
9
|
+
exec(`${opener} "${url}"`);
|
|
10
|
+
}
|
|
11
|
+
export async function reportCommand() {
|
|
12
|
+
const pointerPath = join(process.cwd(), '.hive', 'last-run.json');
|
|
13
|
+
try {
|
|
14
|
+
const pointer = JSON.parse(await readFile(pointerPath, 'utf-8'));
|
|
15
|
+
console.log(`Opening ${pointer.reportUrl}`);
|
|
16
|
+
open(pointer.reportUrl);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
console.log('No runs yet in this project. Run `hivemind run <file>` first.');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function historyCommand(opts) {
|
|
23
|
+
const url = `${serverUrl()}/api/runs`;
|
|
24
|
+
const res = await fetch(url).catch(() => null);
|
|
25
|
+
if (!res || !res.ok) {
|
|
26
|
+
console.error(`Could not reach hivemind server at ${serverUrl()}. Is it running? (hivemind-server)`);
|
|
27
|
+
process.exitCode = 2;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const history = (await res.json());
|
|
31
|
+
if (opts.json) {
|
|
32
|
+
console.log(JSON.stringify(history, null, 2));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
console.log(`Server-wide run history from ${serverUrl()} (not scoped to this project yet):`);
|
|
36
|
+
if (history.length === 0) {
|
|
37
|
+
console.log('No runs yet.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const entry of history) {
|
|
41
|
+
const status = entry.status === 'passed' ? 'PASS' : entry.status.toUpperCase();
|
|
42
|
+
console.log(`${status.padEnd(6)} ${entry.timestamp} ${entry.name}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { loadTestFile } from '../testFile/parse.js';
|
|
4
|
+
import { findTestFiles } from '../testFile/discover.js';
|
|
5
|
+
import { resolveSteps } from '../testFile/resolve.js';
|
|
6
|
+
import { loadConfig, resolveUrl, resolveCredential } from '../config.js';
|
|
7
|
+
import { runOnServer } from '../serverClient.js';
|
|
8
|
+
import { runTestLocally, isLocalUrl } from './runLocal.js';
|
|
9
|
+
import { printResult, printSummary } from '../report/terminal.js';
|
|
10
|
+
import { renderRunPrComment } from '../report/prComment.js';
|
|
11
|
+
import { getChangedFiles, affectedTags, filterByTags } from '../testFile/select.js';
|
|
12
|
+
/** Runs `items` through `worker` with at most `limit` in flight at once. Not fixed batching —
|
|
13
|
+
* each slot immediately pulls the next item as soon as it's free, so a fast test doesn't sit
|
|
14
|
+
* waiting on a slow one in the same "batch". */
|
|
15
|
+
async function runPool(items, limit, worker) {
|
|
16
|
+
let next = 0;
|
|
17
|
+
async function runSlot() {
|
|
18
|
+
while (next < items.length) {
|
|
19
|
+
const i = next++;
|
|
20
|
+
await worker(items[i], i);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runSlot));
|
|
24
|
+
}
|
|
25
|
+
async function readLocalMemory(cwd) {
|
|
26
|
+
try {
|
|
27
|
+
return await readFile(join(cwd, '.hive', 'memory.md'), 'utf-8');
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function writeLocalMemory(cwd, content) {
|
|
34
|
+
const path = join(cwd, '.hive', 'memory.md');
|
|
35
|
+
await mkdir(join(cwd, '.hive'), { recursive: true });
|
|
36
|
+
await writeFile(path, content, 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
export async function runCommand(target, opts) {
|
|
39
|
+
const cwd = process.cwd();
|
|
40
|
+
const config = await loadConfig(cwd);
|
|
41
|
+
let files = await findTestFiles(target);
|
|
42
|
+
if (files.length === 0) {
|
|
43
|
+
console.error(`No test files found at ${target}`);
|
|
44
|
+
process.exitCode = 2;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let loadedTests = await Promise.all(files.map(async (file) => ({ file, test: await loadTestFile(file) })));
|
|
48
|
+
if (opts.changed) {
|
|
49
|
+
const base = typeof opts.changed === 'string' ? opts.changed : 'main';
|
|
50
|
+
const changedFiles = getChangedFiles(cwd, base);
|
|
51
|
+
const tags = affectedTags(changedFiles, config);
|
|
52
|
+
const selected = filterByTags(loadedTests, tags);
|
|
53
|
+
console.log(`--changed ${base}: ${changedFiles.length} file(s) changed, tags [${[...tags].join(', ') || 'none'}], ${selected.length}/${loadedTests.length} test(s) selected`);
|
|
54
|
+
loadedTests = selected;
|
|
55
|
+
files = loadedTests.map((t) => t.file);
|
|
56
|
+
}
|
|
57
|
+
if (files.length === 0) {
|
|
58
|
+
console.log('No tests match — nothing to run.');
|
|
59
|
+
process.exitCode = 0;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const serverUrl = process.env.HIVEMIND_SERVER_URL || 'https://app.thecolonylab.com';
|
|
63
|
+
const memoryContext = await readLocalMemory(cwd);
|
|
64
|
+
const results = new Array(loadedTests.length);
|
|
65
|
+
const testsRoot = join(cwd, 'hive', 'tests');
|
|
66
|
+
const concurrency = Math.max(1, parseInt(opts.concurrency ?? '1', 10) || 1);
|
|
67
|
+
if (concurrency > 1 && !opts.json) {
|
|
68
|
+
console.log(`🐝 Swarm mode — running up to ${concurrency} test(s) at once...`);
|
|
69
|
+
}
|
|
70
|
+
await runPool(loadedTests, concurrency, async ({ test }, index) => {
|
|
71
|
+
let url, credential, resolvedSteps;
|
|
72
|
+
try {
|
|
73
|
+
url = resolveUrl(config, test.url, opts.url, opts.env);
|
|
74
|
+
credential = resolveCredential(config, test.login);
|
|
75
|
+
resolvedSteps = await resolveSteps(test, testsRoot);
|
|
76
|
+
if (test.before.length) {
|
|
77
|
+
console.log(` before: ${test.before.map((b) => b.name).join(', ')} → ${resolvedSteps.length} total steps`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
console.error(`✗ ${test.name}: ${err.message}`);
|
|
82
|
+
results[index] = {
|
|
83
|
+
name: test.name,
|
|
84
|
+
result: { status: 'error', verdict: err.message, steps: [], toolLog: [], screenshots: [], durationMs: 0 },
|
|
85
|
+
};
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Local mode is opt-in ONLY (--local) — never auto-detected. Cloud mode needs nothing extra
|
|
89
|
+
// installed on this machine; local mode needs real Playwright browser binaries present, so
|
|
90
|
+
// silently guessing local would risk a confusing failure on what looked like normal usage.
|
|
91
|
+
// If a local/private URL is targeted without --local, fail fast with a clear reason instead
|
|
92
|
+
// of letting the cloud server attempt (and fail) to reach it.
|
|
93
|
+
if (!opts.local && isLocalUrl(url)) {
|
|
94
|
+
const message = `"${url}" looks like a local/private URL — the cloud server can't reach it. Add --local to run the browser on this machine instead.`;
|
|
95
|
+
console.error(`✗ ${test.name}: ${message}`);
|
|
96
|
+
results[index] = {
|
|
97
|
+
name: test.name,
|
|
98
|
+
result: { status: 'error', verdict: message, steps: [], toolLog: [], screenshots: [], durationMs: 0 },
|
|
99
|
+
};
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const runLocally = !!opts.local;
|
|
103
|
+
try {
|
|
104
|
+
if (runLocally) {
|
|
105
|
+
// Browser runs right here — the only thing this needs from the cloud is decisions,
|
|
106
|
+
// via RemoteLLMClient. No memory folding in local mode yet (v1 scope) — memoryContext
|
|
107
|
+
// is still read for context, just not written back.
|
|
108
|
+
console.log(` 🐝 Forager active — browser on this machine, Hivemind brain via ${serverUrl}`);
|
|
109
|
+
const { result, reportPath } = await runTestLocally({
|
|
110
|
+
cwd,
|
|
111
|
+
serverUrl,
|
|
112
|
+
name: test.name,
|
|
113
|
+
url,
|
|
114
|
+
steps: resolvedSteps,
|
|
115
|
+
memoryContext,
|
|
116
|
+
credential,
|
|
117
|
+
headed: opts.headed,
|
|
118
|
+
});
|
|
119
|
+
await writeFile(join(cwd, '.hive', 'last-run.json'), JSON.stringify({ reportUrl: reportPath, name: test.name }, null, 2)).catch(() => { });
|
|
120
|
+
results[index] = { name: test.name, result, reportUrl: reportPath };
|
|
121
|
+
if (!opts.json) {
|
|
122
|
+
printResult(test.name, result);
|
|
123
|
+
console.log(` report: ${reportPath}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const { result, updatedMemory, reportUrl: reportPath } = await runOnServer({
|
|
128
|
+
name: test.name,
|
|
129
|
+
url,
|
|
130
|
+
steps: resolvedSteps,
|
|
131
|
+
memoryContext,
|
|
132
|
+
credential,
|
|
133
|
+
});
|
|
134
|
+
const reportUrl = `${serverUrl}${reportPath}`;
|
|
135
|
+
await writeLocalMemory(cwd, updatedMemory).catch(() => { });
|
|
136
|
+
await writeFile(join(cwd, '.hive', 'last-run.json'), JSON.stringify({ reportUrl, name: test.name }, null, 2)).catch(() => { });
|
|
137
|
+
results[index] = { name: test.name, result, reportUrl };
|
|
138
|
+
if (!opts.json) {
|
|
139
|
+
printResult(test.name, result);
|
|
140
|
+
console.log(` report: ${reportUrl}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
console.error(`✗ ${test.name}: ${err.message}`);
|
|
146
|
+
results[index] = {
|
|
147
|
+
name: test.name,
|
|
148
|
+
result: { status: 'error', verdict: err.message, steps: [], toolLog: [], screenshots: [], durationMs: 0 },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
if (opts.json) {
|
|
153
|
+
console.log(JSON.stringify(results.map((r) => ({
|
|
154
|
+
name: r.name,
|
|
155
|
+
status: r.result.status,
|
|
156
|
+
verdict: r.result.verdict,
|
|
157
|
+
durationMs: r.result.durationMs,
|
|
158
|
+
steps: r.result.steps,
|
|
159
|
+
reportUrl: r.reportUrl,
|
|
160
|
+
})), null, 2));
|
|
161
|
+
}
|
|
162
|
+
else if (results.length > 1) {
|
|
163
|
+
printSummary(results);
|
|
164
|
+
}
|
|
165
|
+
if (opts.prComment) {
|
|
166
|
+
await writeFile(opts.prComment, renderRunPrComment(results), 'utf-8');
|
|
167
|
+
if (!opts.json)
|
|
168
|
+
console.log(`\nPR comment written to ${opts.prComment}`);
|
|
169
|
+
}
|
|
170
|
+
process.exitCode = results.some((r) => r.result.status !== 'passed') ? 1 : 0;
|
|
171
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { openSession } from '../browser/session.js';
|
|
3
|
+
import { Forager } from './remoteExecutor.js';
|
|
4
|
+
import { createArtifactDir, writeRunArtifacts, artifactDirFor } from '../report/artifacts.js';
|
|
5
|
+
import { readApiKey } from '../credentials.js';
|
|
6
|
+
/** Recognizes localhost / private-network URLs — the browser MUST run locally for these, since
|
|
7
|
+
* no cloud server can reach them. Also usable via an explicit --local flag for anything else
|
|
8
|
+
* (VPN-only staging, etc.) that a cloud server can't reach either. */
|
|
9
|
+
export function isLocalUrl(url) {
|
|
10
|
+
let hostname;
|
|
11
|
+
try {
|
|
12
|
+
hostname = new URL(url).hostname;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1')
|
|
18
|
+
return true;
|
|
19
|
+
if (hostname.endsWith('.local'))
|
|
20
|
+
return true;
|
|
21
|
+
if (/^10\./.test(hostname))
|
|
22
|
+
return true;
|
|
23
|
+
if (/^192\.168\./.test(hostname))
|
|
24
|
+
return true;
|
|
25
|
+
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname))
|
|
26
|
+
return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Local execution mode: the browser runs right here (so it can reach localhost/private
|
|
31
|
+
* networks), but every decision — what to click, what it means, whether the test passed — is
|
|
32
|
+
* made entirely by the server, exactly like a cloud run. This machine just executes the one
|
|
33
|
+
* instruction it's given each time and reports back; it never decides anything itself.
|
|
34
|
+
*/
|
|
35
|
+
export async function runTestLocally(opts) {
|
|
36
|
+
const { runId } = await createArtifactDir(opts.cwd, opts.name);
|
|
37
|
+
const session = await openSession({ headed: opts.headed });
|
|
38
|
+
const executor = new Forager(session.page, Date.now());
|
|
39
|
+
let closed = false;
|
|
40
|
+
try {
|
|
41
|
+
const wsUrl = opts.serverUrl.replace(/^http/, 'ws') + '/ws/local-run';
|
|
42
|
+
const apiKey = await readApiKey();
|
|
43
|
+
const result = await new Promise((resolve, reject) => {
|
|
44
|
+
const headers = { 'User-Agent': 'Hivemind-Forager/0.1.0' };
|
|
45
|
+
if (apiKey)
|
|
46
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
47
|
+
const socket = new WebSocket(wsUrl, { headers });
|
|
48
|
+
socket.on('open', () => {
|
|
49
|
+
socket.send(JSON.stringify({
|
|
50
|
+
type: 'start',
|
|
51
|
+
name: opts.name,
|
|
52
|
+
url: opts.url,
|
|
53
|
+
steps: opts.steps,
|
|
54
|
+
memoryContext: opts.memoryContext,
|
|
55
|
+
credential: opts.credential,
|
|
56
|
+
}));
|
|
57
|
+
});
|
|
58
|
+
socket.on('message', async (raw) => {
|
|
59
|
+
let msg;
|
|
60
|
+
try {
|
|
61
|
+
msg = JSON.parse(raw.toString());
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (msg.type === 'call') {
|
|
67
|
+
const toolResult = await executor.execute(msg.name, msg.input);
|
|
68
|
+
socket.send(JSON.stringify({ type: 'result', callId: msg.callId, ...toolResult }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (msg.type === 'finish') {
|
|
72
|
+
resolve(msg.result);
|
|
73
|
+
socket.close();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (msg.type === 'error') {
|
|
77
|
+
reject(new Error(msg.message));
|
|
78
|
+
socket.close();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
socket.on('error', (err) => reject(err));
|
|
82
|
+
socket.on('close', (code) => {
|
|
83
|
+
if (code !== 1000 && code !== 1005)
|
|
84
|
+
reject(new Error(`Connection to hivemind server closed unexpectedly (code ${code})`));
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
// result.screenshots already carries the actual base64 image bytes this executor captured —
|
|
88
|
+
// the server only relayed them back in the finish message, it never had its own copies.
|
|
89
|
+
await session.close();
|
|
90
|
+
closed = true;
|
|
91
|
+
await writeRunArtifacts(opts.cwd, runId, opts.name, result);
|
|
92
|
+
const reportPath = `${artifactDirFor(opts.cwd, runId)}/report.html`;
|
|
93
|
+
return { result, reportPath };
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (!closed)
|
|
97
|
+
await session.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export async function loadConfig(cwd) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = await readFile(join(cwd, 'hivemind.config.json'), 'utf-8');
|
|
6
|
+
return JSON.parse(raw);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Resolves a test's `Login: <label>` against hivemind.config.json's accounts. Password comes
|
|
13
|
+
* from the env var named in passwordEnv — never stored in the config file or the test file. */
|
|
14
|
+
export function resolveCredential(config, label) {
|
|
15
|
+
if (!label)
|
|
16
|
+
return undefined;
|
|
17
|
+
const account = config.accounts?.[label];
|
|
18
|
+
if (!account) {
|
|
19
|
+
throw new Error(`Test references Login: ${label}, but no account with that label is configured in hivemind.config.json's "accounts".`);
|
|
20
|
+
}
|
|
21
|
+
const password = process.env[account.passwordEnv];
|
|
22
|
+
if (!password) {
|
|
23
|
+
throw new Error(`Account "${label}" needs environment variable ${account.passwordEnv} to be set (referenced by hivemind.config.json), but it's not.`);
|
|
24
|
+
}
|
|
25
|
+
return { label, username: account.username, password };
|
|
26
|
+
}
|
|
27
|
+
export function resolveUrl(config, testUrl, cliUrl, env) {
|
|
28
|
+
if (cliUrl)
|
|
29
|
+
return cliUrl;
|
|
30
|
+
if (env && config.environments?.[env])
|
|
31
|
+
return config.environments[env];
|
|
32
|
+
if (testUrl)
|
|
33
|
+
return testUrl;
|
|
34
|
+
if (config.environments?.default)
|
|
35
|
+
return config.environments.default;
|
|
36
|
+
throw new Error('No URL resolved. Pass --url, set URL: in the test file, or set environments.default in hivemind.config.json');
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
/** Where `hivemind login <key>` stores the API key — global to this machine, not per-project,
|
|
5
|
+
* since the same person usually runs hivemind against many different repos. */
|
|
6
|
+
function credentialsPath() {
|
|
7
|
+
return join(homedir(), '.hivemind', 'credentials.json');
|
|
8
|
+
}
|
|
9
|
+
export async function readApiKey() {
|
|
10
|
+
if (process.env.HIVEMIND_API_KEY)
|
|
11
|
+
return process.env.HIVEMIND_API_KEY;
|
|
12
|
+
try {
|
|
13
|
+
const raw = await readFile(credentialsPath(), 'utf-8');
|
|
14
|
+
return JSON.parse(raw).apiKey;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function writeApiKey(key) {
|
|
21
|
+
const path = credentialsPath();
|
|
22
|
+
await mkdir(join(homedir(), '.hivemind'), { recursive: true });
|
|
23
|
+
await writeFile(path, JSON.stringify({ apiKey: key }, null, 2), 'utf-8');
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdir, writeFile, readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { renderHtmlReport } from './html.js';
|
|
4
|
+
import { slugify } from '../testFile/write.js';
|
|
5
|
+
function timestampDir() {
|
|
6
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
7
|
+
}
|
|
8
|
+
export function artifactDirFor(cwd, runId) {
|
|
9
|
+
return join(cwd, 'artifacts', runId);
|
|
10
|
+
}
|
|
11
|
+
/** Creates the artifact directory for a run BEFORE it starts, so ToolExecutor can write screenshots into it. */
|
|
12
|
+
export async function createArtifactDir(cwd, testName) {
|
|
13
|
+
const runId = `${timestampDir()}-${slugify(testName)}`;
|
|
14
|
+
const artifactDir = artifactDirFor(cwd, runId);
|
|
15
|
+
await mkdir(artifactDir, { recursive: true });
|
|
16
|
+
return { runId, artifactDir };
|
|
17
|
+
}
|
|
18
|
+
/** Writes report.html + history record for a completed run into its (already-created) artifact dir. */
|
|
19
|
+
export async function writeRunArtifacts(cwd, runId, testName, result) {
|
|
20
|
+
const artifactDir = artifactDirFor(cwd, runId);
|
|
21
|
+
const html = renderHtmlReport(testName, result);
|
|
22
|
+
await writeFile(join(artifactDir, 'report.html'), html, 'utf-8');
|
|
23
|
+
const historyDir = join(cwd, '.hive', 'history');
|
|
24
|
+
await mkdir(historyDir, { recursive: true });
|
|
25
|
+
const entry = {
|
|
26
|
+
name: testName,
|
|
27
|
+
status: result.status,
|
|
28
|
+
verdict: result.verdict,
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
runId,
|
|
31
|
+
durationMs: result.durationMs,
|
|
32
|
+
};
|
|
33
|
+
await writeFile(join(historyDir, `${runId}.json`), JSON.stringify(entry, null, 2), 'utf-8');
|
|
34
|
+
return artifactDir;
|
|
35
|
+
}
|
|
36
|
+
export async function readHistory(cwd) {
|
|
37
|
+
const historyDir = join(cwd, '.hive', 'history');
|
|
38
|
+
try {
|
|
39
|
+
const files = await readdir(historyDir);
|
|
40
|
+
const entries = await Promise.all(files
|
|
41
|
+
.filter((f) => f.endsWith('.json'))
|
|
42
|
+
.map(async (f) => JSON.parse(await readFile(join(historyDir, f), 'utf-8'))));
|
|
43
|
+
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|