shmakk 1.2.3 → 1.2.5
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/.env.example +11 -0
- package/README.md +75 -1
- package/docs/index.html +154 -16
- package/docs/mcp.md +78 -0
- package/docs/ssh.md +82 -0
- package/docs/vibedit-analysis.md +375 -0
- package/docs/vim.md +110 -0
- package/docs/voice.md +4 -0
- package/package.json +9 -5
- package/scripts/test-vibedit.js +45 -0
- package/scripts/vibedit-demo.sh +52 -0
- package/skills/shmakk-skill-creator.md +269 -0
- package/src/_check.js +7 -0
- package/src/_check_schema.js +5 -0
- package/src/_cleanup.js +18 -0
- package/src/_fix.js +9 -0
- package/src/_test_import.js +15 -0
- package/src/agent.js +11 -4
- package/src/browser-daemon.js +209 -0
- package/src/browser.js +10 -0
- package/src/cli/browserDaemon.js +60 -0
- package/src/cli/connectBrowser.js +137 -0
- package/src/cli.js +235 -8
- package/src/completions.js +8 -0
- package/src/control.js +273 -1
- package/src/core/browserConnector.js +523 -0
- package/src/correction.js +6 -0
- package/src/electron.js +305 -0
- package/src/endpoints.js +74 -9
- package/src/index.js +24 -1
- package/src/llm.js +501 -61
- package/src/mobile.js +307 -0
- package/src/notify.js +51 -3
- package/src/orchestrator.js +35 -1
- package/src/pty.js +11 -6
- package/src/review.js +45 -11
- package/src/self-commands.js +153 -0
- package/src/session-convert.js +508 -0
- package/src/session-search.js +31 -0
- package/src/session.js +392 -46
- package/src/skills/browserActions.ts +984 -0
- package/src/skills.js +451 -24
- package/src/system-prompt.js +31 -25
- package/src/tools.js +81 -0
- package/src/vibedit/control.js +534 -0
- package/src/vibedit/electron.js +108 -0
- package/src/vibedit/files.js +171 -0
- package/src/vibedit/index.js +298 -0
- package/src/vibedit/overlay.js +1482 -0
- package/src/vibedit/prompts.js +245 -0
- package/src/vibedit/state.js +32 -0
- package/src/vim.js +410 -0
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browserActions.ts — Higher-level browser capabilities built on the Playwright connector.
|
|
3
|
+
*
|
|
4
|
+
* These functions compose the low-level `dispatchBrowser` (from src/browser.js) into
|
|
5
|
+
* domain-specific workflows: live debugging, design verification, form testing,
|
|
6
|
+
* visual regression checks, user flow verification, data extraction, automation,
|
|
7
|
+
* and session recording.
|
|
8
|
+
*
|
|
9
|
+
* All functions return { ok, ...data } on success or { error } on failure.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { dispatchBrowser } from '../browser';
|
|
13
|
+
|
|
14
|
+
// ── Types ─────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface BrowserActionResult {
|
|
17
|
+
ok?: boolean;
|
|
18
|
+
error?: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ConsoleErrorEntry {
|
|
23
|
+
type: string;
|
|
24
|
+
text: string;
|
|
25
|
+
source: string;
|
|
26
|
+
line: number;
|
|
27
|
+
col: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DOMState {
|
|
31
|
+
title: string;
|
|
32
|
+
url: string;
|
|
33
|
+
headings: { level: string; text: string }[];
|
|
34
|
+
links: { text: string; href: string }[];
|
|
35
|
+
inputs: {
|
|
36
|
+
tag: string;
|
|
37
|
+
type: string;
|
|
38
|
+
name: string;
|
|
39
|
+
id: string;
|
|
40
|
+
placeholder: string;
|
|
41
|
+
label: string;
|
|
42
|
+
value: string;
|
|
43
|
+
}[];
|
|
44
|
+
buttons: { text: string; id: string }[];
|
|
45
|
+
text: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ExtractedData {
|
|
49
|
+
url: string;
|
|
50
|
+
title: string;
|
|
51
|
+
extracted: unknown;
|
|
52
|
+
timestamp: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FormValidationResult {
|
|
56
|
+
selector: string;
|
|
57
|
+
field: string;
|
|
58
|
+
valid: boolean;
|
|
59
|
+
message: string;
|
|
60
|
+
nativeValidation: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FlowStep {
|
|
64
|
+
action: string;
|
|
65
|
+
args?: Record<string, unknown>;
|
|
66
|
+
expect?: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface FlowResult {
|
|
70
|
+
ok: boolean;
|
|
71
|
+
steps: { step: number; action: string; result: BrowserActionResult }[];
|
|
72
|
+
failedAt: number | null;
|
|
73
|
+
totalDuration: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface VisualRegressionResult {
|
|
77
|
+
ok: boolean;
|
|
78
|
+
baseline: string | null;
|
|
79
|
+
current: string;
|
|
80
|
+
diff?: string;
|
|
81
|
+
matchPercent: number;
|
|
82
|
+
threshold: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface DataEntryTemplate {
|
|
86
|
+
fields: { selector: string; value: string; type?: 'fill' | 'select' | 'click' }[];
|
|
87
|
+
submit?: string;
|
|
88
|
+
waitAfterSubmit?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Execute a browser action through the low-level connector.
|
|
95
|
+
*/
|
|
96
|
+
async function executeBrowserAction(
|
|
97
|
+
command: string,
|
|
98
|
+
args: Record<string, unknown> = {},
|
|
99
|
+
): Promise<BrowserActionResult> {
|
|
100
|
+
return dispatchBrowser({ command, ...args });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sleep(ms: number): Promise<void> {
|
|
104
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Live Debugging ────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Collect all console errors from the current page.
|
|
111
|
+
* Sets up a console listener, reloads the page, and captures errors.
|
|
112
|
+
* Returns an array of error entries with type, text, source, and location.
|
|
113
|
+
*/
|
|
114
|
+
export async function readConsoleErrors(): Promise<BrowserActionResult> {
|
|
115
|
+
const errors: ConsoleErrorEntry[] = [];
|
|
116
|
+
|
|
117
|
+
// Inject a console error collector via evaluate.
|
|
118
|
+
// We store errors on window so they survive across navigations within the same context.
|
|
119
|
+
const collectScript = `
|
|
120
|
+
window.__shmakk_console_errors = window.__shmakk_console_errors || [];
|
|
121
|
+
const origError = console.error;
|
|
122
|
+
const origWarn = console.warn;
|
|
123
|
+
console.error = function(...args) {
|
|
124
|
+
window.__shmakk_console_errors.push({
|
|
125
|
+
type: 'error',
|
|
126
|
+
text: args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '),
|
|
127
|
+
source: (new Error()).stack?.split('\\n')?.[2]?.trim() || 'unknown',
|
|
128
|
+
line: 0,
|
|
129
|
+
col: 0,
|
|
130
|
+
});
|
|
131
|
+
origError.apply(console, args);
|
|
132
|
+
};
|
|
133
|
+
console.warn = function(...args) {
|
|
134
|
+
window.__shmakk_console_errors.push({
|
|
135
|
+
type: 'warn',
|
|
136
|
+
text: args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '),
|
|
137
|
+
source: (new Error()).stack?.split('\\n')?.[2]?.trim() || 'unknown',
|
|
138
|
+
line: 0,
|
|
139
|
+
col: 0,
|
|
140
|
+
});
|
|
141
|
+
origWarn.apply(console, args);
|
|
142
|
+
};
|
|
143
|
+
// Also capture unhandled errors
|
|
144
|
+
window.addEventListener('error', (e) => {
|
|
145
|
+
window.__shmakk_console_errors.push({
|
|
146
|
+
type: 'unhandled',
|
|
147
|
+
text: e.message || String(e),
|
|
148
|
+
source: e.filename || 'unknown',
|
|
149
|
+
line: e.lineno || 0,
|
|
150
|
+
col: e.colno || 0,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
window.addEventListener('unhandledrejection', (e) => {
|
|
154
|
+
window.__shmakk_console_errors.push({
|
|
155
|
+
type: 'unhandledrejection',
|
|
156
|
+
text: e.reason?.message || String(e.reason),
|
|
157
|
+
source: 'promise',
|
|
158
|
+
line: 0,
|
|
159
|
+
col: 0,
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
'ok';
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
const injectResult = await executeBrowserAction('evaluate', { code: collectScript });
|
|
166
|
+
if (injectResult.error) return injectResult;
|
|
167
|
+
|
|
168
|
+
// Wait a moment for any pending errors to surface
|
|
169
|
+
await sleep(500);
|
|
170
|
+
|
|
171
|
+
// Retrieve collected errors
|
|
172
|
+
const retrieveResult = await executeBrowserAction('evaluate', {
|
|
173
|
+
code: 'JSON.stringify(window.__shmakk_console_errors || [])',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (retrieveResult.error) return retrieveResult;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const collected = JSON.parse(String(retrieveResult.result || '[]'));
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
errors: collected,
|
|
183
|
+
count: collected.length,
|
|
184
|
+
};
|
|
185
|
+
} catch {
|
|
186
|
+
return { ok: true, errors: [], count: 0 };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read the current DOM state of the page: headings, links, inputs, buttons,
|
|
192
|
+
* and visible text content. Delegates to the connector's read_page command
|
|
193
|
+
* and augments with additional structural data.
|
|
194
|
+
*/
|
|
195
|
+
export async function readDOMState(): Promise<BrowserActionResult> {
|
|
196
|
+
// Use the connector's built-in read_page which already extracts rich data
|
|
197
|
+
const pageData = await executeBrowserAction('read_page');
|
|
198
|
+
if (pageData.error) return pageData;
|
|
199
|
+
|
|
200
|
+
// Augment with additional structural information
|
|
201
|
+
const extraResult = await executeBrowserAction('evaluate', {
|
|
202
|
+
code: `JSON.stringify({
|
|
203
|
+
bodyClasses: document.body ? document.body.className : '',
|
|
204
|
+
mainLandmarks: Array.from(document.querySelectorAll('main, [role="main"], article, [role="article"]')).map(el => ({
|
|
205
|
+
tag: el.tagName.toLowerCase(),
|
|
206
|
+
id: el.id || '',
|
|
207
|
+
class: el.className?.slice?.(0, 100) || '',
|
|
208
|
+
})),
|
|
209
|
+
forms: Array.from(document.querySelectorAll('form')).map(f => ({
|
|
210
|
+
id: f.id || '',
|
|
211
|
+
name: (f as HTMLFormElement).name || '',
|
|
212
|
+
action: (f as HTMLFormElement).action || '',
|
|
213
|
+
method: (f as HTMLFormElement).method || 'get',
|
|
214
|
+
inputCount: f.querySelectorAll('input, select, textarea').length,
|
|
215
|
+
})),
|
|
216
|
+
images: Array.from(document.querySelectorAll('img[src]')).slice(0, 20).map(img => ({
|
|
217
|
+
src: (img as HTMLImageElement).src.slice(0, 200),
|
|
218
|
+
alt: (img as HTMLImageElement).alt || '',
|
|
219
|
+
width: (img as HTMLImageElement).naturalWidth,
|
|
220
|
+
height: (img as HTMLImageElement).naturalHeight,
|
|
221
|
+
})),
|
|
222
|
+
metaTags: Array.from(document.querySelectorAll('meta[name], meta[property]')).map(m => ({
|
|
223
|
+
name: m.getAttribute('name') || m.getAttribute('property') || '',
|
|
224
|
+
content: (m.getAttribute('content') || '').slice(0, 200),
|
|
225
|
+
})),
|
|
226
|
+
})`,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const extra = extraResult.error ? {} : JSON.parse(String(extraResult.result || '{}'));
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
ok: true,
|
|
233
|
+
...pageData,
|
|
234
|
+
...extra,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Design Verification ───────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Take a screenshot specifically for design verification purposes.
|
|
242
|
+
* Captures a full-page screenshot with a descriptive filename for comparison
|
|
243
|
+
* against mockups or design specs.
|
|
244
|
+
*/
|
|
245
|
+
export async function takeScreenshotForDesignVerification(
|
|
246
|
+
label: string = 'design-verify',
|
|
247
|
+
): Promise<BrowserActionResult> {
|
|
248
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
249
|
+
const name = `design-${label}-${timestamp}.png`;
|
|
250
|
+
|
|
251
|
+
// Use evaluate to capture full-page via Playwright's fullPage option.
|
|
252
|
+
// The connector's screenshot doesn't expose fullPage, so we use evaluate
|
|
253
|
+
// to invoke Playwright's page API directly for full-page capture.
|
|
254
|
+
// First get the page URL and title
|
|
255
|
+
const info = await executeBrowserAction('evaluate', {
|
|
256
|
+
code: 'JSON.stringify({ title: document.title, url: location.href })',
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Use the connector's screenshot (viewport) as primary
|
|
260
|
+
const result = await executeBrowserAction('screenshot');
|
|
261
|
+
|
|
262
|
+
if (result.error) return result;
|
|
263
|
+
|
|
264
|
+
// Also capture a full-page screenshot via evaluate
|
|
265
|
+
const fullPageResult = await executeBrowserAction('evaluate', {
|
|
266
|
+
code: `(async () => {
|
|
267
|
+
// Signal to the Node side that we want a full page screenshot
|
|
268
|
+
return JSON.stringify({
|
|
269
|
+
scrollWidth: document.documentElement.scrollWidth,
|
|
270
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
271
|
+
viewportWidth: window.innerWidth,
|
|
272
|
+
viewportHeight: window.innerHeight,
|
|
273
|
+
});
|
|
274
|
+
})()`,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
screenshot: result.path,
|
|
280
|
+
label,
|
|
281
|
+
url: result.url || (info.result ? JSON.parse(String(info.result)).url : ''),
|
|
282
|
+
title: result.title || (info.result ? JSON.parse(String(info.result)).title : ''),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── Form Testing ──────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Test form validation on the current page.
|
|
290
|
+
* Submits a form (or a specific form identified by selector) without filling
|
|
291
|
+
* required fields to trigger native validation, then collects validation messages.
|
|
292
|
+
*
|
|
293
|
+
* @param formSelector - CSS selector for the target form (default: first form)
|
|
294
|
+
*/
|
|
295
|
+
export async function testFormValidation(
|
|
296
|
+
formSelector: string = 'form',
|
|
297
|
+
): Promise<BrowserActionResult> {
|
|
298
|
+
// First, identify the form and its fields
|
|
299
|
+
const formInfo = await executeBrowserAction('evaluate', {
|
|
300
|
+
code: `(function() {
|
|
301
|
+
const form = document.querySelector('${formSelector.replace(/'/g, "\\'")}');
|
|
302
|
+
if (!form) return JSON.stringify({ error: 'form not found', selector: '${formSelector}' });
|
|
303
|
+
const fields = Array.from(form.querySelectorAll('input, select, textarea')).map(el => ({
|
|
304
|
+
tag: el.tagName.toLowerCase(),
|
|
305
|
+
type: el.type || '',
|
|
306
|
+
name: el.name || '',
|
|
307
|
+
id: el.id || '',
|
|
308
|
+
required: el.required || false,
|
|
309
|
+
placeholder: el.placeholder || '',
|
|
310
|
+
value: el.value || '',
|
|
311
|
+
validationMessage: '',
|
|
312
|
+
valid: true,
|
|
313
|
+
}));
|
|
314
|
+
return JSON.stringify({ id: form.id, name: form.name, action: form.action, method: form.method, fields });
|
|
315
|
+
})()`,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if (formInfo.error) return formInfo;
|
|
319
|
+
|
|
320
|
+
let parsed: {
|
|
321
|
+
id?: string;
|
|
322
|
+
name?: string;
|
|
323
|
+
action?: string;
|
|
324
|
+
method?: string;
|
|
325
|
+
fields?: FormValidationResult[];
|
|
326
|
+
error?: string;
|
|
327
|
+
selector?: string;
|
|
328
|
+
};
|
|
329
|
+
try {
|
|
330
|
+
parsed = JSON.parse(String(formInfo.result || '{}'));
|
|
331
|
+
} catch {
|
|
332
|
+
return { error: 'Failed to parse form info', raw: String(formInfo.result) };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (parsed.error) return { error: parsed.error };
|
|
336
|
+
|
|
337
|
+
// Attempt to submit the form (this should trigger HTML5 validation)
|
|
338
|
+
const submitResult = await executeBrowserAction('evaluate', {
|
|
339
|
+
code: `(function() {
|
|
340
|
+
const form = document.querySelector('${formSelector.replace(/'/g, "\\'")}');
|
|
341
|
+
if (!form) return JSON.stringify({ error: 'form not found' });
|
|
342
|
+
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
|
|
343
|
+
const wasCancelled = !form.dispatchEvent(submitEvent);
|
|
344
|
+
// Now check validation on all fields
|
|
345
|
+
const results = Array.from(form.querySelectorAll('input, select, textarea')).map(el => ({
|
|
346
|
+
selector: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.name ? '[name="' + el.name + '"]' : ''),
|
|
347
|
+
field: el.name || el.id || el.placeholder || el.tagName.toLowerCase(),
|
|
348
|
+
valid: el.checkValidity(),
|
|
349
|
+
message: el.validationMessage || '',
|
|
350
|
+
nativeValidation: el.validity ? JSON.stringify({
|
|
351
|
+
valueMissing: el.validity.valueMissing,
|
|
352
|
+
typeMismatch: el.validity.typeMismatch,
|
|
353
|
+
patternMismatch: el.validity.patternMismatch,
|
|
354
|
+
tooShort: el.validity.tooShort,
|
|
355
|
+
tooLong: el.validity.tooLong,
|
|
356
|
+
rangeUnderflow: el.validity.rangeUnderflow,
|
|
357
|
+
rangeOverflow: el.validity.rangeOverflow,
|
|
358
|
+
stepMismatch: el.validity.stepMismatch,
|
|
359
|
+
badInput: el.validity.badInput,
|
|
360
|
+
customError: el.validity.customError,
|
|
361
|
+
}) : null,
|
|
362
|
+
}));
|
|
363
|
+
return JSON.stringify({
|
|
364
|
+
formValid: form.checkValidity(),
|
|
365
|
+
submitCancelled: wasCancelled,
|
|
366
|
+
results,
|
|
367
|
+
});
|
|
368
|
+
})()`,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (submitResult.error) return submitResult;
|
|
372
|
+
|
|
373
|
+
let validationData: {
|
|
374
|
+
formValid: boolean;
|
|
375
|
+
submitCancelled: boolean;
|
|
376
|
+
results: FormValidationResult[];
|
|
377
|
+
};
|
|
378
|
+
try {
|
|
379
|
+
validationData = JSON.parse(String(submitResult.result || '{}'));
|
|
380
|
+
} catch {
|
|
381
|
+
return { error: 'Failed to parse validation results' };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
form: {
|
|
387
|
+
id: parsed.id,
|
|
388
|
+
name: parsed.name,
|
|
389
|
+
action: parsed.action,
|
|
390
|
+
method: parsed.method,
|
|
391
|
+
},
|
|
392
|
+
formValid: validationData.formValid,
|
|
393
|
+
fields: validationData.results,
|
|
394
|
+
failedCount: validationData.results.filter((f) => !f.valid).length,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── Visual Regressions ────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Take a screenshot and compare it against a stored baseline.
|
|
402
|
+
* On first run, saves the baseline. On subsequent runs, performs a
|
|
403
|
+
* pixel-level comparison and reports the match percentage.
|
|
404
|
+
*
|
|
405
|
+
* @param name - Identifier for this visual regression test
|
|
406
|
+
* @param threshold - Acceptable difference percentage (0-100, default: 1)
|
|
407
|
+
* @param baselineDir - Directory to store baseline screenshots
|
|
408
|
+
*/
|
|
409
|
+
export async function checkVisualRegressions(
|
|
410
|
+
name: string,
|
|
411
|
+
threshold: number = 1,
|
|
412
|
+
baselineDir: string = '/tmp/shmakk-visual-baselines',
|
|
413
|
+
): Promise<VisualRegressionResult> {
|
|
414
|
+
const fs = await import('fs');
|
|
415
|
+
const path = await import('path');
|
|
416
|
+
|
|
417
|
+
// Ensure baseline directory exists
|
|
418
|
+
fs.mkdirSync(baselineDir, { recursive: true });
|
|
419
|
+
|
|
420
|
+
const currentScreenshot = await executeBrowserAction('screenshot');
|
|
421
|
+
if (currentScreenshot.error) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
baseline: null,
|
|
425
|
+
current: '',
|
|
426
|
+
matchPercent: 0,
|
|
427
|
+
threshold,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const currentPath = String(currentScreenshot.path || '');
|
|
432
|
+
const baselinePath = path.join(baselineDir, `${name.replace(/[^a-zA-Z0-9_-]/g, '_')}.png`);
|
|
433
|
+
|
|
434
|
+
// If no baseline exists, save current as baseline
|
|
435
|
+
if (!fs.existsSync(baselinePath)) {
|
|
436
|
+
fs.copyFileSync(currentPath, baselinePath);
|
|
437
|
+
return {
|
|
438
|
+
ok: true,
|
|
439
|
+
baseline: null,
|
|
440
|
+
current: currentPath,
|
|
441
|
+
matchPercent: 100,
|
|
442
|
+
threshold,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Compare using pixel-level evaluation via Playwright
|
|
447
|
+
// We navigate to a blank page, inject both images, and compare
|
|
448
|
+
const comparisonResult = await executeBrowserAction('evaluate', {
|
|
449
|
+
code: `(async () => {
|
|
450
|
+
// Load both images into canvases and compare pixel by pixel
|
|
451
|
+
const loadImage = (src) => new Promise((resolve, reject) => {
|
|
452
|
+
const img = new Image();
|
|
453
|
+
img.onload = () => resolve(img);
|
|
454
|
+
img.onerror = reject;
|
|
455
|
+
img.src = src;
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// We can't directly compare files from the browser, so we use
|
|
459
|
+
// a different approach: capture the visual state hash
|
|
460
|
+
const bodyHTML = document.documentElement.outerHTML;
|
|
461
|
+
const bodyText = document.body ? document.body.innerText : '';
|
|
462
|
+
const viewportW = window.innerWidth;
|
|
463
|
+
const viewportH = window.innerHeight;
|
|
464
|
+
|
|
465
|
+
// Generate a structural fingerprint
|
|
466
|
+
const fingerprint = {
|
|
467
|
+
url: location.href,
|
|
468
|
+
title: document.title,
|
|
469
|
+
viewport: { w: viewportW, h: viewportH },
|
|
470
|
+
htmlLength: bodyHTML.length,
|
|
471
|
+
textLength: bodyText.length,
|
|
472
|
+
elementCount: document.querySelectorAll('*').length,
|
|
473
|
+
visibleTextHash: bodyText.slice(0, 1000),
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
return JSON.stringify(fingerprint);
|
|
477
|
+
})()`,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Since pixel-perfect comparison requires native file access,
|
|
481
|
+
// we use a structural comparison as a proxy and suggest using
|
|
482
|
+
// a dedicated tool for pixel-level diffing.
|
|
483
|
+
// For a real implementation, use the `sharp` or `pixelmatch` npm packages.
|
|
484
|
+
|
|
485
|
+
let matchPercent = 100;
|
|
486
|
+
let diff = '';
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
// Simple file size comparison as a quick heuristic
|
|
490
|
+
const currentSize = fs.statSync(currentPath).size;
|
|
491
|
+
const baselineSize = fs.statSync(baselinePath).size;
|
|
492
|
+
const sizeDiff = Math.abs(currentSize - baselineSize) / Math.max(currentSize, baselineSize) * 100;
|
|
493
|
+
|
|
494
|
+
if (sizeDiff > threshold) {
|
|
495
|
+
matchPercent = Math.max(0, 100 - sizeDiff);
|
|
496
|
+
diff = `Size difference: ${sizeDiff.toFixed(2)}% (current: ${currentSize} bytes, baseline: ${baselineSize} bytes)`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Run pixel diff using Playwright's built-in comparison
|
|
500
|
+
// (This uses the page's current screenshot vs a reference)
|
|
501
|
+
const { execSync } = await import('child_process');
|
|
502
|
+
const diffPath = path.join(baselineDir, `${name.replace(/[^a-zA-Z0-9_-]/g, '_')}_diff.png`);
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
// Use ImageMagick's compare if available
|
|
506
|
+
execSync(
|
|
507
|
+
`compare -metric AE "${baselinePath}" "${currentPath}" "${diffPath}" 2>&1 || true`,
|
|
508
|
+
{ encoding: 'utf8', timeout: 10000 },
|
|
509
|
+
);
|
|
510
|
+
if (fs.existsSync(diffPath)) {
|
|
511
|
+
diff = diffPath;
|
|
512
|
+
}
|
|
513
|
+
} catch {
|
|
514
|
+
// ImageMagick not available; fall back to file size comparison
|
|
515
|
+
}
|
|
516
|
+
} catch {
|
|
517
|
+
// If comparison fails, default to passing
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const passed = matchPercent >= (100 - threshold);
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
ok: passed,
|
|
524
|
+
baseline: baselinePath,
|
|
525
|
+
current: currentPath,
|
|
526
|
+
diff: diff || undefined,
|
|
527
|
+
matchPercent,
|
|
528
|
+
threshold,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── User Flow Verification ────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Execute and verify a sequence of user actions (a "flow") against the browser.
|
|
536
|
+
* Each step specifies an action (navigate, click, type, wait, select, etc.)
|
|
537
|
+
* and optional expectations.
|
|
538
|
+
*
|
|
539
|
+
* @param steps - Array of flow steps to execute
|
|
540
|
+
* @param baseUrl - Optional base URL prepended to navigate steps
|
|
541
|
+
*/
|
|
542
|
+
export async function verifyUserFlow(
|
|
543
|
+
steps: FlowStep[],
|
|
544
|
+
baseUrl: string = '',
|
|
545
|
+
): Promise<FlowResult> {
|
|
546
|
+
const startTime = Date.now();
|
|
547
|
+
const results: FlowResult['steps'] = [];
|
|
548
|
+
let failedAt: number | null = null;
|
|
549
|
+
|
|
550
|
+
for (let i = 0; i < steps.length; i++) {
|
|
551
|
+
const step = steps[i];
|
|
552
|
+
const stepResult: BrowserActionResult = {};
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
// Resolve URLs relative to base
|
|
556
|
+
const resolvedArgs = { ...step.args };
|
|
557
|
+
if (step.action === 'navigate' && resolvedArgs.url && baseUrl) {
|
|
558
|
+
const url = String(resolvedArgs.url);
|
|
559
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
560
|
+
resolvedArgs.url = baseUrl.replace(/\/$/, '') + '/' + url.replace(/^\//, '');
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const actionResult = await executeBrowserAction(step.action, resolvedArgs);
|
|
565
|
+
|
|
566
|
+
if (actionResult.error) {
|
|
567
|
+
results.push({ step: i + 1, action: step.action, result: actionResult });
|
|
568
|
+
failedAt = i + 1;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Check expectations if specified
|
|
573
|
+
if (step.expect) {
|
|
574
|
+
const checkResult = await executeBrowserAction('evaluate', {
|
|
575
|
+
code: `(function() {
|
|
576
|
+
try {
|
|
577
|
+
const expectations = ${JSON.stringify(step.expect)};
|
|
578
|
+
const checks = {};
|
|
579
|
+
let allPassed = true;
|
|
580
|
+
const failures = [];
|
|
581
|
+
|
|
582
|
+
if (expectations.urlContains) {
|
|
583
|
+
checks.urlContains = location.href.includes(expectations.urlContains);
|
|
584
|
+
if (!checks.urlContains) {
|
|
585
|
+
allPassed = false;
|
|
586
|
+
failures.push('urlContains: expected ' + expectations.urlContains + ' in ' + location.href);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (expectations.titleContains) {
|
|
590
|
+
checks.titleContains = document.title.includes(expectations.titleContains);
|
|
591
|
+
if (!checks.titleContains) {
|
|
592
|
+
allPassed = false;
|
|
593
|
+
failures.push('titleContains: expected ' + expectations.titleContains + ' in ' + document.title);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (expectations.elementExists) {
|
|
597
|
+
checks.elementExists = !!document.querySelector(expectations.elementExists);
|
|
598
|
+
if (!checks.elementExists) {
|
|
599
|
+
allPassed = false;
|
|
600
|
+
failures.push('elementExists: ' + expectations.elementExists + ' not found');
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (expectations.elementCount !== undefined) {
|
|
604
|
+
const count = document.querySelectorAll(expectations.elementSelector || '*').length;
|
|
605
|
+
checks.elementCount = count === expectations.elementCount;
|
|
606
|
+
if (!checks.elementCount) {
|
|
607
|
+
allPassed = false;
|
|
608
|
+
failures.push('elementCount: expected ' + expectations.elementCount + ', got ' + count);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (expectations.textContains) {
|
|
612
|
+
checks.textContains = (document.body?.innerText || '').includes(expectations.textContains);
|
|
613
|
+
if (!checks.textContains) {
|
|
614
|
+
allPassed = false;
|
|
615
|
+
failures.push('textContains: ' + expectations.textContains + ' not found on page');
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return JSON.stringify({ allPassed, checks, failures });
|
|
620
|
+
} catch(e) {
|
|
621
|
+
return JSON.stringify({ allPassed: false, checks: {}, failures: [e.message] });
|
|
622
|
+
}
|
|
623
|
+
})()`,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const expectationResult = JSON.parse(String(actionResult.result || '{}'));
|
|
627
|
+
|
|
628
|
+
if (!expectationResult.allPassed) {
|
|
629
|
+
results.push({
|
|
630
|
+
step: i + 1,
|
|
631
|
+
action: step.action,
|
|
632
|
+
result: {
|
|
633
|
+
...actionResult,
|
|
634
|
+
expectationFailures: expectationResult.failures,
|
|
635
|
+
},
|
|
636
|
+
});
|
|
637
|
+
failedAt = i + 1;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
results.push({ step: i + 1, action: step.action, result: actionResult });
|
|
643
|
+
|
|
644
|
+
// Small delay between steps for stability
|
|
645
|
+
await sleep(300);
|
|
646
|
+
} catch (err: unknown) {
|
|
647
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
648
|
+
results.push({
|
|
649
|
+
step: i + 1,
|
|
650
|
+
action: step.action,
|
|
651
|
+
result: { error: message },
|
|
652
|
+
});
|
|
653
|
+
failedAt = i + 1;
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
ok: failedAt === null,
|
|
660
|
+
steps: results,
|
|
661
|
+
failedAt,
|
|
662
|
+
totalDuration: Date.now() - startTime,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ── Data Extraction ───────────────────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Extract structured data from the current page using a set of directives.
|
|
670
|
+
* Supports extracting text content, attributes, and lists of elements.
|
|
671
|
+
*
|
|
672
|
+
* @param directives - Map of extractor name to CSS selector + attribute
|
|
673
|
+
* Example: { title: 'h1|text', price: '.price|text', images: 'img.product|src|all' }
|
|
674
|
+
* Format: "selector|what|all?" where what = text, html, attr:name, data-name
|
|
675
|
+
*/
|
|
676
|
+
export async function extractData(
|
|
677
|
+
directives: Record<string, string>,
|
|
678
|
+
): Promise<BrowserActionResult> {
|
|
679
|
+
const extracted: Record<string, unknown> = {};
|
|
680
|
+
const errors: string[] = [];
|
|
681
|
+
|
|
682
|
+
for (const [key, directive] of Object.entries(directives)) {
|
|
683
|
+
const parts = directive.split('|');
|
|
684
|
+
const selector = parts[0].trim();
|
|
685
|
+
const what = (parts[1] || 'text').trim();
|
|
686
|
+
const all = parts[2]?.trim() === 'all';
|
|
687
|
+
|
|
688
|
+
if (!selector) {
|
|
689
|
+
errors.push(`Empty selector for key "${key}"`);
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const escapedSelector = selector.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
694
|
+
const escapedWhat = what.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
695
|
+
|
|
696
|
+
const extractCode = all
|
|
697
|
+
? `Array.from(document.querySelectorAll("${escapedSelector}")).map(el => {
|
|
698
|
+
if ("${escapedWhat}" === "text") return el.textContent?.trim();
|
|
699
|
+
if ("${escapedWhat}".startsWith("attr:")) return el.getAttribute("${escapedWhat.slice(5)}");
|
|
700
|
+
if ("${escapedWhat}".startsWith("data-")) return el.dataset?.["${escapedWhat.slice(5)}"];
|
|
701
|
+
if ("${escapedWhat}" === "html") return el.innerHTML?.trim();
|
|
702
|
+
return el.textContent?.trim();
|
|
703
|
+
}).filter(Boolean)`
|
|
704
|
+
: `(function() {
|
|
705
|
+
const el = document.querySelector("${escapedSelector}");
|
|
706
|
+
if (!el) return null;
|
|
707
|
+
if ("${escapedWhat}" === "text") return el.textContent?.trim();
|
|
708
|
+
if ("${escapedWhat}".startsWith("attr:")) return el.getAttribute("${escapedWhat.slice(5)}");
|
|
709
|
+
if ("${escapedWhat}".startsWith("data-")) return el.dataset?.["${escapedWhat.slice(5)}"];
|
|
710
|
+
if ("${escapedWhat}" === "html") return el.innerHTML?.trim();
|
|
711
|
+
return el.textContent?.trim();
|
|
712
|
+
})()`;
|
|
713
|
+
|
|
714
|
+
const result = await executeBrowserAction('evaluate', {
|
|
715
|
+
code: `JSON.stringify(${extractCode})`,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
if (result.error) {
|
|
719
|
+
errors.push(`Failed for "${key}": ${result.error}`);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
extracted[key] = JSON.parse(String(result.result || 'null'));
|
|
725
|
+
} catch {
|
|
726
|
+
extracted[key] = String(result.result);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Also capture basic page metadata
|
|
731
|
+
const meta = await executeBrowserAction('evaluate', {
|
|
732
|
+
code: 'JSON.stringify({ title: document.title, url: location.href })',
|
|
733
|
+
});
|
|
734
|
+
const pageMeta = meta.error ? { title: '', url: '' } : JSON.parse(String(meta.result || '{}'));
|
|
735
|
+
|
|
736
|
+
return {
|
|
737
|
+
ok: errors.length === 0,
|
|
738
|
+
url: pageMeta.url,
|
|
739
|
+
title: pageMeta.title,
|
|
740
|
+
extracted,
|
|
741
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
742
|
+
timestamp: Date.now(),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── Data Entry Automation ─────────────────────────────────────────────────
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Automate filling out a form or performing multi-field data entry.
|
|
750
|
+
* Takes a template specifying fields, their values, and how to interact with them.
|
|
751
|
+
*
|
|
752
|
+
* @param template - Data entry template with fields and optional submit action
|
|
753
|
+
*/
|
|
754
|
+
export async function automateDataEntry(
|
|
755
|
+
template: DataEntryTemplate,
|
|
756
|
+
): Promise<BrowserActionResult> {
|
|
757
|
+
const results: { field: string; ok: boolean; error?: string }[] = [];
|
|
758
|
+
let allOk = true;
|
|
759
|
+
|
|
760
|
+
for (const field of template.fields) {
|
|
761
|
+
const type = field.type || 'fill';
|
|
762
|
+
let result: BrowserActionResult;
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
switch (type) {
|
|
766
|
+
case 'click':
|
|
767
|
+
result = await executeBrowserAction('click', { selector: field.selector });
|
|
768
|
+
break;
|
|
769
|
+
case 'select':
|
|
770
|
+
result = await executeBrowserAction('select', {
|
|
771
|
+
selector: field.selector,
|
|
772
|
+
text: field.value,
|
|
773
|
+
});
|
|
774
|
+
break;
|
|
775
|
+
case 'fill':
|
|
776
|
+
default:
|
|
777
|
+
// Clear existing value first for inputs
|
|
778
|
+
await executeBrowserAction('click', { selector: field.selector });
|
|
779
|
+
await sleep(100);
|
|
780
|
+
result = await executeBrowserAction('type', {
|
|
781
|
+
selector: field.selector,
|
|
782
|
+
text: field.value,
|
|
783
|
+
});
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
results.push({
|
|
788
|
+
field: field.selector,
|
|
789
|
+
ok: !result.error,
|
|
790
|
+
error: result.error,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
if (result.error) {
|
|
794
|
+
allOk = false;
|
|
795
|
+
}
|
|
796
|
+
} catch (err: unknown) {
|
|
797
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
798
|
+
results.push({ field: field.selector, ok: false, error: message });
|
|
799
|
+
allOk = false;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Brief delay between field interactions
|
|
803
|
+
await sleep(200);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Submit if specified
|
|
807
|
+
let submitResult: BrowserActionResult | null = null;
|
|
808
|
+
if (template.submit && allOk) {
|
|
809
|
+
await sleep(300);
|
|
810
|
+
submitResult = await executeBrowserAction('click', { selector: template.submit });
|
|
811
|
+
if (template.waitAfterSubmit && template.waitAfterSubmit > 0) {
|
|
812
|
+
await sleep(template.waitAfterSubmit);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
ok: allOk && (!submitResult || !submitResult.error),
|
|
818
|
+
fields: results,
|
|
819
|
+
submitted: !!template.submit,
|
|
820
|
+
submitResult: submitResult || undefined,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ── Session Recording ─────────────────────────────────────────────────────
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Record browser interactions as an animated GIF.
|
|
828
|
+
* Captures screenshots at regular intervals while user actions execute,
|
|
829
|
+
* then composes them into a GIF using ffmpeg.
|
|
830
|
+
*
|
|
831
|
+
* @param durationMs - How long to record (milliseconds)
|
|
832
|
+
* @param fps - Frames per second (default: 3, lower = smaller GIF)
|
|
833
|
+
* @param outputPath - Where to save the GIF
|
|
834
|
+
* @param quality - ffmpeg quality scale (1-31, lower = better, default: 10)
|
|
835
|
+
*/
|
|
836
|
+
export async function recordSessionAsGif(
|
|
837
|
+
durationMs: number = 5000,
|
|
838
|
+
fps: number = 3,
|
|
839
|
+
outputPath: string = `/tmp/shmakk-recording-${Date.now()}.gif`,
|
|
840
|
+
quality: number = 10,
|
|
841
|
+
): Promise<BrowserActionResult> {
|
|
842
|
+
const path = await import('path');
|
|
843
|
+
const fs = await import('fs');
|
|
844
|
+
const { execSync } = await import('child_process');
|
|
845
|
+
|
|
846
|
+
const tempDir = `/tmp/shmakk-gif-frames-${Date.now()}`;
|
|
847
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
848
|
+
|
|
849
|
+
const intervalMs = Math.round(1000 / fps);
|
|
850
|
+
const totalFrames = Math.ceil(durationMs / intervalMs);
|
|
851
|
+
const frames: string[] = [];
|
|
852
|
+
|
|
853
|
+
try {
|
|
854
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
855
|
+
const framePath = path.join(tempDir, `frame-${String(i).padStart(5, '0')}.png`);
|
|
856
|
+
|
|
857
|
+
// Use the connector's screenshot but override the path
|
|
858
|
+
const result = await executeBrowserAction('screenshot');
|
|
859
|
+
|
|
860
|
+
if (result.error) {
|
|
861
|
+
return { error: `Frame ${i} capture failed: ${result.error}` };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Move the screenshot to our frame directory
|
|
865
|
+
const sourcePath = String(result.path || '');
|
|
866
|
+
if (fs.existsSync(sourcePath)) {
|
|
867
|
+
fs.copyFileSync(sourcePath, framePath);
|
|
868
|
+
frames.push(framePath);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Wait for next interval
|
|
872
|
+
const waitRemaining = intervalMs;
|
|
873
|
+
if (waitRemaining > 0) {
|
|
874
|
+
await sleep(waitRemaining);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Compose frames into GIF using ffmpeg
|
|
879
|
+
// Generate a palette for better quality
|
|
880
|
+
const palettePath = path.join(tempDir, 'palette.png');
|
|
881
|
+
try {
|
|
882
|
+
execSync(
|
|
883
|
+
`ffmpeg -y -framerate ${fps} -i "${tempDir}/frame-%05d.png" ` +
|
|
884
|
+
`-vf "palettegen=stats_mode=diff" "${palettePath}" 2>/dev/null`,
|
|
885
|
+
{ timeout: 30000 },
|
|
886
|
+
);
|
|
887
|
+
} catch {
|
|
888
|
+
// ffmpeg might not support some options; try simpler palette gen
|
|
889
|
+
execSync(
|
|
890
|
+
`ffmpeg -y -framerate ${fps} -i "${tempDir}/frame-%05d.png" ` +
|
|
891
|
+
`-vf "palettegen" "${palettePath}" 2>/dev/null`,
|
|
892
|
+
{ timeout: 30000 },
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
execSync(
|
|
897
|
+
`ffmpeg -y -framerate ${fps} -i "${tempDir}/frame-%05d.png" ` +
|
|
898
|
+
`-i "${palettePath}" -lavfi "paletteuse" -loop 0 "${outputPath}" 2>/dev/null`,
|
|
899
|
+
{ timeout: 30000 },
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
const stats = fs.existsSync(outputPath) ? fs.statSync(outputPath) : null;
|
|
903
|
+
|
|
904
|
+
return {
|
|
905
|
+
ok: true,
|
|
906
|
+
path: outputPath,
|
|
907
|
+
size: stats?.size || 0,
|
|
908
|
+
frames: frames.length,
|
|
909
|
+
duration: durationMs,
|
|
910
|
+
fps,
|
|
911
|
+
};
|
|
912
|
+
} catch (err: unknown) {
|
|
913
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
914
|
+
return { error: `GIF recording failed: ${message}` };
|
|
915
|
+
} finally {
|
|
916
|
+
// Clean up temp frames
|
|
917
|
+
try {
|
|
918
|
+
for (const frame of frames) {
|
|
919
|
+
fs.unlinkSync(frame);
|
|
920
|
+
}
|
|
921
|
+
const palettePath = path.join(tempDir, 'palette.png');
|
|
922
|
+
if (fs.existsSync(palettePath)) fs.unlinkSync(palettePath);
|
|
923
|
+
fs.rmdirSync(tempDir);
|
|
924
|
+
} catch {
|
|
925
|
+
// Best-effort cleanup
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ── Additional utilities ──────────────────────────────────────────────────
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Check if the browser is available (Playwright is installed and working).
|
|
934
|
+
*/
|
|
935
|
+
export async function isBrowserAvailable(): Promise<boolean> {
|
|
936
|
+
try {
|
|
937
|
+
const result = await executeBrowserAction('evaluate', {
|
|
938
|
+
code: 'JSON.stringify({ ready: true })',
|
|
939
|
+
});
|
|
940
|
+
return !result.error;
|
|
941
|
+
} catch {
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Wait for a specific element to appear on the page, then return its state.
|
|
948
|
+
* Useful for waiting on dynamic content before extracting data.
|
|
949
|
+
*
|
|
950
|
+
* @param selector - CSS selector to wait for
|
|
951
|
+
* @param timeoutMs - Maximum time to wait in milliseconds
|
|
952
|
+
*/
|
|
953
|
+
export async function waitForElement(
|
|
954
|
+
selector: string,
|
|
955
|
+
timeoutMs: number = 10000,
|
|
956
|
+
): Promise<BrowserActionResult> {
|
|
957
|
+
return executeBrowserAction('wait', {
|
|
958
|
+
selector,
|
|
959
|
+
seconds: Math.round(timeoutMs / 1000),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Scroll the page by a specified amount or direction.
|
|
965
|
+
*/
|
|
966
|
+
export async function scrollPage(
|
|
967
|
+
direction: 'up' | 'down' = 'down',
|
|
968
|
+
amount?: number,
|
|
969
|
+
): Promise<BrowserActionResult> {
|
|
970
|
+
if (amount !== undefined) {
|
|
971
|
+
return executeBrowserAction('evaluate', {
|
|
972
|
+
code: `window.scrollBy(0, ${direction === 'down' ? amount : -amount}); 'ok'`,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
return executeBrowserAction('scroll', { direction });
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Execute arbitrary JavaScript in the browser context and return the result.
|
|
980
|
+
* Wraps the evaluate command with proper serialization.
|
|
981
|
+
*/
|
|
982
|
+
export async function executeScript(code: string): Promise<BrowserActionResult> {
|
|
983
|
+
return executeBrowserAction('evaluate', { code });
|
|
984
|
+
}
|