codeply-cli 0.4.0 → 0.5.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/lib/agent.mjs +12 -4
- package/lib/bots.js +590 -0
- package/lib/cloud-browser.mjs +143 -0
- package/lib/cloud.mjs +483 -86
- package/lib/native-tools.mjs +2 -1
- package/lib/oauth-connectors.js +51 -9
- package/lib/tools.mjs +40 -12
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real browser for the cloud runner: headless Chrome (preinstalled on
|
|
3
|
+
* GitHub's ubuntu runners), driven over the DevTools protocol with Node's own
|
|
4
|
+
* WebSocket, so browser_check works in the cloud the way it does in the app:
|
|
5
|
+
* console errors, failed requests, broken images, sideways scroll, and a
|
|
6
|
+
* screenshot the model looks at and the chat shows.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
|
|
13
|
+
const CANDIDATES = [
|
|
14
|
+
process.env.CHROME_PATH,
|
|
15
|
+
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
16
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
17
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
18
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
19
|
+
].filter(Boolean);
|
|
20
|
+
|
|
21
|
+
const VIEWPORTS = { desktop: { width: 1280, height: 800, mobile: false }, tablet: { width: 768, height: 1024, mobile: true }, mobile: { width: 390, height: 844, mobile: true } };
|
|
22
|
+
const MOBILE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
|
|
23
|
+
|
|
24
|
+
export function findChrome() {
|
|
25
|
+
return CANDIDATES.find((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function viewportFor(v) {
|
|
29
|
+
const key = String(v || 'desktop').toLowerCase();
|
|
30
|
+
if (VIEWPORTS[key]) return { name: key, ...VIEWPORTS[key] };
|
|
31
|
+
const m = /^(\d{3,4})\s*[x×]\s*(\d{3,4})$/.exec(key);
|
|
32
|
+
if (m) return { name: `${m[1]}x${m[2]}`, width: Number(m[1]), height: Number(m[2]), mobile: Number(m[1]) < 1024 };
|
|
33
|
+
return { name: 'desktop', ...VIEWPORTS.desktop };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One DevTools connection: send(method, params) and on(event, fn). */
|
|
37
|
+
function cdp(wsUrl) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const ws = new WebSocket(wsUrl);
|
|
40
|
+
let id = 0;
|
|
41
|
+
const pending = new Map();
|
|
42
|
+
const handlers = new Map();
|
|
43
|
+
ws.onopen = () => resolve({
|
|
44
|
+
send: (method, params = {}, sessionId) => new Promise((res, rej) => {
|
|
45
|
+
const msgId = ++id;
|
|
46
|
+
pending.set(msgId, { res, rej });
|
|
47
|
+
ws.send(JSON.stringify({ id: msgId, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
48
|
+
}),
|
|
49
|
+
on: (event, fn) => handlers.set(event, [...(handlers.get(event) || []), fn]),
|
|
50
|
+
close: () => { try { ws.close(); } catch {} },
|
|
51
|
+
});
|
|
52
|
+
ws.onerror = () => reject(new Error('Could not talk to Chrome.'));
|
|
53
|
+
ws.onmessage = (m) => {
|
|
54
|
+
const msg = JSON.parse(typeof m.data === 'string' ? m.data : Buffer.from(m.data).toString());
|
|
55
|
+
if (msg.id && pending.has(msg.id)) {
|
|
56
|
+
const p = pending.get(msg.id); pending.delete(msg.id);
|
|
57
|
+
if (msg.error) p.rej(new Error(msg.error.message)); else p.res(msg.result);
|
|
58
|
+
} else if (msg.method) for (const fn of handlers.get(msg.method) || []) fn(msg.params, msg.sessionId);
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* makeCloudBrowser() returns a browser(url, { wait, viewport }) function for
|
|
65
|
+
* the agent, or null when no Chrome is installed. Each check gets a fresh tab;
|
|
66
|
+
* Chrome itself starts once and is closed by close().
|
|
67
|
+
*/
|
|
68
|
+
export function makeCloudBrowser({ chromePath = findChrome(), shotsDir = path.join(os.tmpdir(), 'craft-shots') } = {}) {
|
|
69
|
+
if (!chromePath) return null;
|
|
70
|
+
let started = null;
|
|
71
|
+
let shot = 0;
|
|
72
|
+
const start = () => {
|
|
73
|
+
if (started) return started;
|
|
74
|
+
started = (async () => {
|
|
75
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-chrome-'));
|
|
76
|
+
const proc = spawn(chromePath, ['--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--hide-scrollbars',
|
|
77
|
+
'--remote-debugging-port=0', `--user-data-dir=${dir}`, 'about:blank'], { stdio: 'ignore' });
|
|
78
|
+
const portFile = path.join(dir, 'DevToolsActivePort');
|
|
79
|
+
for (let i = 0; i < 100 && !fs.existsSync(portFile); i++) await new Promise((r) => setTimeout(r, 100));
|
|
80
|
+
if (!fs.existsSync(portFile)) { proc.kill(); throw new Error('Chrome did not start.'); }
|
|
81
|
+
const [port, wsPath] = fs.readFileSync(portFile, 'utf8').trim().split('\n');
|
|
82
|
+
const conn = await cdp(`ws://127.0.0.1:${port}${wsPath}`);
|
|
83
|
+
return { proc, conn, dir };
|
|
84
|
+
})();
|
|
85
|
+
return started;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const browser = async (url, { wait = 1200, viewport } = {}) => {
|
|
89
|
+
const { conn } = await start();
|
|
90
|
+
const vp = viewportFor(viewport);
|
|
91
|
+
const { targetId } = await conn.send('Target.createTarget', { url: 'about:blank' });
|
|
92
|
+
const { sessionId } = await conn.send('Target.attachToTarget', { targetId, flatten: true });
|
|
93
|
+
const s = (m, p) => conn.send(m, p, sessionId);
|
|
94
|
+
const consoleErrors = []; const consoleWarnings = []; const failedRequests = [];
|
|
95
|
+
conn.on('Runtime.consoleAPICalled', (p, sid) => {
|
|
96
|
+
if (sid !== sessionId) return;
|
|
97
|
+
const text = (p.args || []).map((a) => a.value ?? a.description ?? '').join(' ').slice(0, 300);
|
|
98
|
+
if (p.type === 'error') consoleErrors.push(text); else if (p.type === 'warning') consoleWarnings.push(text);
|
|
99
|
+
});
|
|
100
|
+
conn.on('Runtime.exceptionThrown', (p, sid) => { if (sid === sessionId) consoleErrors.push(String((p.exceptionDetails.exception && p.exceptionDetails.exception.description) || p.exceptionDetails.text).split('\n')[0].slice(0, 300)); });
|
|
101
|
+
conn.on('Network.responseReceived', (p, sid) => { if (sid === sessionId && p.response.status >= 400) failedRequests.push(`HTTP ${p.response.status} - ${p.response.url}`); });
|
|
102
|
+
conn.on('Network.loadingFailed', (p, sid) => { if (sid === sessionId && !p.canceled) failedRequests.push(`${p.errorText} - request ${p.requestId}`); });
|
|
103
|
+
try {
|
|
104
|
+
await s('Page.enable'); await s('Runtime.enable'); await s('Network.enable');
|
|
105
|
+
await s('Emulation.setDeviceMetricsOverride', { width: vp.width, height: vp.height, deviceScaleFactor: 1, mobile: vp.mobile });
|
|
106
|
+
if (vp.mobile) await s('Emulation.setUserAgentOverride', { userAgent: MOBILE_UA });
|
|
107
|
+
const loaded = new Promise((r) => { conn.on('Page.loadEventFired', (p, sid) => { if (sid === sessionId) r(); }); setTimeout(r, 15000); });
|
|
108
|
+
const nav = await s('Page.navigate', { url });
|
|
109
|
+
if (nav.errorText) return { ok: false, error: nav.errorText };
|
|
110
|
+
await loaded;
|
|
111
|
+
await new Promise((r) => setTimeout(r, Math.min(8000, wait)));
|
|
112
|
+
const { result } = await s('Runtime.evaluate', {
|
|
113
|
+
returnByValue: true,
|
|
114
|
+
expression: `(() => {
|
|
115
|
+
const w = document.documentElement.scrollWidth, vw = window.innerWidth;
|
|
116
|
+
const wide = w > vw + 1 ? [...document.querySelectorAll('body *')].filter((e) => e.getBoundingClientRect().right > vw + 1).slice(0, 5).map((e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : e.className && typeof e.className === 'string' ? '.' + e.className.split(' ')[0] : '')) : [];
|
|
117
|
+
return { title: document.title, text: (document.body && document.body.innerText || '').slice(0, 3000),
|
|
118
|
+
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src).slice(0, 20),
|
|
119
|
+
pageWidth: w, viewportWidth: vw, overflowX: w > vw + 1, wideElements: wide,
|
|
120
|
+
hasViewportMeta: !!document.querySelector('meta[name="viewport"]') };
|
|
121
|
+
})()`,
|
|
122
|
+
});
|
|
123
|
+
const shotData = await s('Page.captureScreenshot', { format: 'png' });
|
|
124
|
+
fs.mkdirSync(shotsDir, { recursive: true });
|
|
125
|
+
const screenshotPath = path.join(shotsDir, `check-${++shot}.png`);
|
|
126
|
+
fs.writeFileSync(screenshotPath, Buffer.from(shotData.data, 'base64'));
|
|
127
|
+
return {
|
|
128
|
+
ok: true, ...result.value, viewport: `${vp.name} ${vp.width}x${vp.height}`,
|
|
129
|
+
consoleErrors, consoleWarnings, failedRequests,
|
|
130
|
+
screenshotPath, screenshotDataUrl: `data:image/png;base64,${shotData.data}`,
|
|
131
|
+
};
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return { ok: false, error: e.message };
|
|
134
|
+
} finally {
|
|
135
|
+
conn.send('Target.closeTarget', { targetId }).catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
browser.close = async () => {
|
|
139
|
+
if (!started) return;
|
|
140
|
+
try { const { proc, conn } = await started; conn.close(); proc.kill(); } catch {}
|
|
141
|
+
};
|
|
142
|
+
return browser;
|
|
143
|
+
}
|