argusqa-os 9.6.6 → 9.7.4
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/README.md +394 -384
- package/glama.json +2 -2
- package/package.json +77 -71
- package/src/adapters/browser.js +11 -3
- package/src/cli/chrome-launcher.js +175 -0
- package/src/cli/doctor.js +133 -0
- package/src/cli/pr-validate.js +25 -6
- package/src/mcp-server.js +27 -9
- package/src/orchestration/orchestrator.js +9 -7
- package/src/orchestration/report-processor.js +33 -1
- package/src/orchestration/watch-mode.js +20 -0
- package/src/utils/a11y-deep-analyzer.js +1 -1
- package/src/utils/contract-validator.js +27 -2
- package/src/utils/design-fidelity-analyzer.js +1 -1
- package/src/utils/flow-runner.js +16 -2
- package/src/utils/font-analyzer.js +1 -1
- package/src/utils/form-analyzer.js +1 -1
- package/src/utils/har-recorder.js +1 -1
- package/src/utils/issues-analyzer.js +12 -19
- package/src/utils/mcp-parsers.js +20 -0
- package/src/utils/motion-analyzer.js +1 -1
- package/src/utils/noise-filter.js +159 -0
- package/src/utils/pdf-exporter.js +146 -0
- package/src/utils/pr-diff-analyzer.js +11 -2
- package/src/utils/root-cause-linker.js +175 -0
- package/src/utils/screen-recorder.js +250 -0
- package/src/utils/security-analyzer.js +132 -1
- package/src/utils/theme-analyzer.js +1 -1
- package/src/utils/visual-diff-analyzer.js +1 -1
- package/src/utils/web-vitals-analyzer.js +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus PDF Exporter
|
|
3
|
+
*
|
|
4
|
+
* Exports the Argus HTML report as a branded A4 PDF using puppeteer.
|
|
5
|
+
* puppeteer is an optional peer dependency — install when needed:
|
|
6
|
+
* npm install puppeteer
|
|
7
|
+
*
|
|
8
|
+
* Usage (programmatic):
|
|
9
|
+
* import { exportReportToPdf } from './pdf-exporter.js';
|
|
10
|
+
* const pdfPath = await exportReportToPdf('./reports/report.html', './reports/report.pdf');
|
|
11
|
+
*
|
|
12
|
+
* Usage (CLI):
|
|
13
|
+
* node src/utils/pdf-exporter.js ./reports/report.html ./reports/report.pdf
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
19
|
+
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load puppeteer dynamically so the import failure is a clear runtime error,
|
|
24
|
+
* not a module load error on startup.
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<object>} puppeteer default export
|
|
27
|
+
* @throws {Error} with install instructions if not installed
|
|
28
|
+
*/
|
|
29
|
+
async function loadPuppeteer() {
|
|
30
|
+
try {
|
|
31
|
+
return (await import('puppeteer')).default;
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'PDF export requires puppeteer:\n' +
|
|
35
|
+
' npm install puppeteer\n' +
|
|
36
|
+
'Then retry.'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Export an Argus HTML report to a branded A4 PDF.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} htmlPath - Absolute or relative path to the source HTML report
|
|
45
|
+
* @param {string} outputPath - Destination PDF file path (written to disk)
|
|
46
|
+
* @param {{ format?: string, landscape?: boolean, scale?: number }} [options]
|
|
47
|
+
* @returns {Promise<string>} Resolved outputPath
|
|
48
|
+
*/
|
|
49
|
+
export async function exportReportToPdf(htmlPath, outputPath, options = {}) {
|
|
50
|
+
const {
|
|
51
|
+
format = 'A4',
|
|
52
|
+
landscape = false,
|
|
53
|
+
scale = 1,
|
|
54
|
+
} = options;
|
|
55
|
+
|
|
56
|
+
const resolvedHtml = path.resolve(htmlPath);
|
|
57
|
+
if (!fs.existsSync(resolvedHtml)) {
|
|
58
|
+
throw new Error(`HTML report not found: ${resolvedHtml}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const resolvedOut = path.resolve(outputPath);
|
|
62
|
+
fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
|
|
63
|
+
|
|
64
|
+
const puppeteer = await loadPuppeteer();
|
|
65
|
+
const browser = await puppeteer.launch({ headless: 'new' });
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const page = await browser.newPage();
|
|
69
|
+
await page.setViewport({ width: 1280, height: 900 });
|
|
70
|
+
await page.goto(pathToFileURL(resolvedHtml).href, { waitUntil: 'networkidle0', timeout: 30_000 });
|
|
71
|
+
|
|
72
|
+
await page.pdf({
|
|
73
|
+
path: resolvedOut,
|
|
74
|
+
format,
|
|
75
|
+
landscape,
|
|
76
|
+
scale,
|
|
77
|
+
printBackground: true,
|
|
78
|
+
margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
|
|
79
|
+
});
|
|
80
|
+
} finally {
|
|
81
|
+
await browser.close();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return resolvedOut;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Navigate to a live URL and export to PDF.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} pageUrl - URL to navigate to before printing
|
|
91
|
+
* @param {string} outputPath - Destination PDF file path
|
|
92
|
+
* @param {{ format?: string, landscape?: boolean, scale?: number, waitUntil?: string }} [options]
|
|
93
|
+
* @returns {Promise<string>}
|
|
94
|
+
*/
|
|
95
|
+
export async function exportPageToPdf(pageUrl, outputPath, options = {}) {
|
|
96
|
+
const {
|
|
97
|
+
format = 'A4',
|
|
98
|
+
landscape = false,
|
|
99
|
+
scale = 1,
|
|
100
|
+
waitUntil = 'networkidle0',
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
const resolvedOut = path.resolve(outputPath);
|
|
104
|
+
fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
|
|
105
|
+
|
|
106
|
+
const puppeteer = await loadPuppeteer();
|
|
107
|
+
const browser = await puppeteer.launch({ headless: 'new' });
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const page = await browser.newPage();
|
|
111
|
+
await page.setViewport({ width: 1280, height: 900 });
|
|
112
|
+
await page.goto(pageUrl, { waitUntil, timeout: 30_000 });
|
|
113
|
+
|
|
114
|
+
await page.pdf({
|
|
115
|
+
path: resolvedOut,
|
|
116
|
+
format,
|
|
117
|
+
landscape,
|
|
118
|
+
scale,
|
|
119
|
+
printBackground: true,
|
|
120
|
+
margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
|
|
121
|
+
});
|
|
122
|
+
} finally {
|
|
123
|
+
await browser.close();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return resolvedOut;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── CLI entry ─────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
if (process.argv[1] === __filename) {
|
|
132
|
+
const [,, htmlArg, outArg] = process.argv;
|
|
133
|
+
|
|
134
|
+
if (!htmlArg || !outArg) {
|
|
135
|
+
process.stderr.write('Usage: node src/utils/pdf-exporter.js <report.html> <output.pdf>\n');
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const out = await exportReportToPdf(htmlArg, outArg);
|
|
141
|
+
process.stdout.write(`✓ PDF written: ${out}\n`);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
process.stderr.write(`✗ ${err.message}\n`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -7,8 +7,16 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Pure functions + one async fetch — no Chrome, no MCP, no AI verdict.
|
|
9
9
|
* AI verdict logic ships separately in the private argus-pro repo.
|
|
10
|
+
*
|
|
11
|
+
* This module is imported by the MCP server — nothing here may write to
|
|
12
|
+
* stdout (reserved for JSON-RPC). CI annotations are emitted by the CLI
|
|
13
|
+
* entry point (src/cli/pr-validate.js), which owns stdout.
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
import { childLogger } from './logger.js';
|
|
17
|
+
|
|
18
|
+
const logger = childLogger('pr-diff-analyzer');
|
|
19
|
+
|
|
12
20
|
/**
|
|
13
21
|
* Parse a GitHub PR URL into its owner/repo/prNumber components.
|
|
14
22
|
*
|
|
@@ -59,7 +67,7 @@ export async function fetchPrFiles(prUrl, githubToken) {
|
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
if (allFiles.length >= 300) {
|
|
62
|
-
|
|
70
|
+
logger.warn('[ARGUS] PR has 300+ changed files — Argus analyzed the first 300. Routes affected by later files may be missed.');
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
return allFiles;
|
|
@@ -81,8 +89,9 @@ const EXCLUDED_PATTERNS = [
|
|
|
81
89
|
/**
|
|
82
90
|
* Patterns that indicate an infrastructure-level file whose change can affect
|
|
83
91
|
* every route — framework configs, root layouts, global stylesheets, package.json.
|
|
92
|
+
* Exported for reuse by root-cause-linker.js (MIT).
|
|
84
93
|
*/
|
|
85
|
-
const INFRA_PATTERNS = [
|
|
94
|
+
export const INFRA_PATTERNS = [
|
|
86
95
|
/next\.config\./i,
|
|
87
96
|
/vite\.config\./i,
|
|
88
97
|
/tailwind\.config\./i,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Cause Linking — recent git changes → suspect files per finding.
|
|
3
|
+
*
|
|
4
|
+
* Pure git-diff heuristic: no external API, no AI call. Reads the last N
|
|
5
|
+
* commits from the local repo (`git log --name-only`), maps changed files to
|
|
6
|
+
* route paths with the same slug heuristic as pr-diff-analyzer, and annotates
|
|
7
|
+
* each NEW finding (isNew: true) with the files/commits most likely to have
|
|
8
|
+
* caused it:
|
|
9
|
+
*
|
|
10
|
+
* finding.rootCause = {
|
|
11
|
+
* files: ['src/pages/checkout/Form.jsx', ...], // ≤ MAX_FILES
|
|
12
|
+
* commits: [{ hash: 'abc1234', subject: '...' }], // ≤ MAX_COMMITS
|
|
13
|
+
* global: false, // true when only infra-level files matched
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* Only new findings are annotated — a finding that predates the recent commits
|
|
17
|
+
* cannot have been caused by them. Source repo defaults to ARGUS_SOURCE_DIR or
|
|
18
|
+
* the current working directory. Disable with ARGUS_ROOT_CAUSE=0.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execSync } from 'child_process';
|
|
22
|
+
import { INFRA_PATTERNS } from './pr-diff-analyzer.js';
|
|
23
|
+
import { childLogger } from './logger.js';
|
|
24
|
+
|
|
25
|
+
const logger = childLogger('root-cause-linker');
|
|
26
|
+
|
|
27
|
+
/** Commits inspected by default. */
|
|
28
|
+
export const DEFAULT_COMMITS = 10;
|
|
29
|
+
/** Max suspect files attached to one finding. */
|
|
30
|
+
const MAX_FILES = 5;
|
|
31
|
+
/** Max commits attached to one finding. */
|
|
32
|
+
const MAX_COMMITS = 3;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the last N commits with their changed files from a local git repo.
|
|
36
|
+
* Returns [] when the directory is not a repo, git is unavailable, or the
|
|
37
|
+
* command fails — root cause linking is always best-effort.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} [repoDir] - Defaults to ARGUS_SOURCE_DIR, then cwd
|
|
40
|
+
* @param {object} [opts]
|
|
41
|
+
* @param {number} [opts.commits]
|
|
42
|
+
* @returns {Array<{ hash: string, subject: string, files: string[] }>}
|
|
43
|
+
*/
|
|
44
|
+
export function getRecentChanges(repoDir = process.env.ARGUS_SOURCE_DIR || process.cwd(), { commits = DEFAULT_COMMITS } = {}) {
|
|
45
|
+
let out;
|
|
46
|
+
try {
|
|
47
|
+
out = execSync(`git log --name-only --pretty=format:%h%x09%s -n ${Math.max(1, commits | 0)}`, {
|
|
48
|
+
cwd: repoDir,
|
|
49
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
+
timeout: 5000,
|
|
51
|
+
}).toString();
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const changes = [];
|
|
57
|
+
let current = null;
|
|
58
|
+
for (const line of out.split('\n')) {
|
|
59
|
+
// Detect commit lines on the RAW line — an empty commit subject leaves a
|
|
60
|
+
// trailing tab ("abc1234\t") that trimEnd() would strip, misclassifying
|
|
61
|
+
// the hash as a file path. File lines never contain raw tabs: git quotes
|
|
62
|
+
// paths with special characters (core.quotePath octal escapes).
|
|
63
|
+
if (line.includes('\t')) {
|
|
64
|
+
const [hash, ...subjectParts] = line.trimEnd().split('\t');
|
|
65
|
+
current = { hash, subject: subjectParts.join('\t'), files: [] };
|
|
66
|
+
changes.push(current);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const trimmed = line.trimEnd();
|
|
70
|
+
if (!trimmed) continue;
|
|
71
|
+
if (current) {
|
|
72
|
+
// Git emits paths with forward slashes on every platform
|
|
73
|
+
current.files.push(trimmed);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return changes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Match changed file paths against one route path using the slug heuristic
|
|
81
|
+
* from pr-diff-analyzer: a file matches when any of its path/name slugs equals
|
|
82
|
+
* a route path segment. Infra-level files (configs, root layouts, global CSS)
|
|
83
|
+
* match every route and are reported via `global`.
|
|
84
|
+
*
|
|
85
|
+
* @param {string[]} files
|
|
86
|
+
* @param {string} routePath - e.g. "/checkout/review"
|
|
87
|
+
* @returns {{ files: string[], global: boolean }}
|
|
88
|
+
*/
|
|
89
|
+
export function matchFilesToRoutePath(files, routePath) {
|
|
90
|
+
const segments = String(routePath ?? '')
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.split('/')
|
|
93
|
+
.map(s => s.replace(/[^a-z0-9]/g, ''))
|
|
94
|
+
.filter(s => s.length > 1);
|
|
95
|
+
|
|
96
|
+
const matched = [];
|
|
97
|
+
let global = false;
|
|
98
|
+
|
|
99
|
+
for (const file of (files ?? [])) {
|
|
100
|
+
if (INFRA_PATTERNS.some(re => re.test(file))) {
|
|
101
|
+
global = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (segments.length === 0) continue; // home route "/" — only infra files apply
|
|
105
|
+
const slugs = file
|
|
106
|
+
.toLowerCase()
|
|
107
|
+
.replace(/\.[^./\\]+$/, '')
|
|
108
|
+
.split(/[/\\._-]+/)
|
|
109
|
+
.filter(s => s.length > 1);
|
|
110
|
+
if (segments.some(seg => slugs.includes(seg))) matched.push(file);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { files: matched, global };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Annotate NEW findings in the report with `rootCause` (mutates in place).
|
|
118
|
+
*
|
|
119
|
+
* For each route, slug-matches every recent commit's files against the route's
|
|
120
|
+
* URL pathname. Direct file matches win; when only infra-level files changed,
|
|
121
|
+
* the annotation carries `global: true` with those files as suspects.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} report - { routes: [{ url, errors }] }; findings need isNew
|
|
124
|
+
* @param {Array} changes - From getRecentChanges()
|
|
125
|
+
* @returns {{ linkedCount: number }}
|
|
126
|
+
*/
|
|
127
|
+
export function linkRootCauses(report, changes) {
|
|
128
|
+
let linkedCount = 0;
|
|
129
|
+
if (!Array.isArray(changes) || changes.length === 0) return { linkedCount };
|
|
130
|
+
|
|
131
|
+
for (const routeResult of (report.routes ?? [])) {
|
|
132
|
+
let routePath;
|
|
133
|
+
try {
|
|
134
|
+
routePath = new URL(routeResult.url).pathname;
|
|
135
|
+
} catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Collect suspect files + commits for this route across the recent commits
|
|
140
|
+
const directFiles = [];
|
|
141
|
+
const infraFiles = [];
|
|
142
|
+
const directCommits = [];
|
|
143
|
+
const infraCommits = [];
|
|
144
|
+
for (const commit of changes) {
|
|
145
|
+
const { files, global } = matchFilesToRoutePath(commit.files, routePath);
|
|
146
|
+
if (files.length > 0) {
|
|
147
|
+
directFiles.push(...files);
|
|
148
|
+
directCommits.push({ hash: commit.hash, subject: commit.subject });
|
|
149
|
+
} else if (global) {
|
|
150
|
+
infraFiles.push(...commit.files.filter(f => INFRA_PATTERNS.some(re => re.test(f))));
|
|
151
|
+
infraCommits.push({ hash: commit.hash, subject: commit.subject });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const useDirect = directFiles.length > 0;
|
|
156
|
+
const suspectFiles = [...new Set(useDirect ? directFiles : infraFiles)].slice(0, MAX_FILES);
|
|
157
|
+
const suspectCommits = (useDirect ? directCommits : infraCommits).slice(0, MAX_COMMITS);
|
|
158
|
+
if (suspectFiles.length === 0) continue;
|
|
159
|
+
|
|
160
|
+
for (const finding of (routeResult.errors ?? [])) {
|
|
161
|
+
if (!finding.isNew) continue; // pre-existing findings predate these commits
|
|
162
|
+
finding.rootCause = {
|
|
163
|
+
files: suspectFiles,
|
|
164
|
+
commits: suspectCommits,
|
|
165
|
+
global: !useDirect,
|
|
166
|
+
};
|
|
167
|
+
linkedCount++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (linkedCount > 0) {
|
|
172
|
+
logger.info(`[ARGUS] Root cause linking: ${linkedCount} new finding(s) linked to recent commits`);
|
|
173
|
+
}
|
|
174
|
+
return { linkedCount };
|
|
175
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argus Screen Recorder
|
|
3
|
+
*
|
|
4
|
+
* Records an audit session as a series of JPEG frames by periodically
|
|
5
|
+
* calling browser.screenshot(). Frames are saved to reports/recordings/<id>/.
|
|
6
|
+
*
|
|
7
|
+
* For genuine CDP screencast (Page.startScreencast) instead of polling,
|
|
8
|
+
* install the optional `ws` package:
|
|
9
|
+
* npm install ws
|
|
10
|
+
* then use CdpScreenRecorder (exported below) which connects directly to
|
|
11
|
+
* Chrome's WebSocket debugger URL.
|
|
12
|
+
*
|
|
13
|
+
* Usage (polling recorder — no extra deps):
|
|
14
|
+
* import { PollingRecorder } from './screen-recorder.js';
|
|
15
|
+
* const rec = new PollingRecorder(browser, { intervalMs: 500 });
|
|
16
|
+
* rec.start();
|
|
17
|
+
* // ... run audit ...
|
|
18
|
+
* const outputDir = await rec.stop('./reports/recordings/my-run');
|
|
19
|
+
*
|
|
20
|
+
* Usage (CDP screencast — requires ws):
|
|
21
|
+
* import { CdpScreenRecorder } from './screen-recorder.js';
|
|
22
|
+
* const rec = await CdpScreenRecorder.create(9222);
|
|
23
|
+
* await rec.start();
|
|
24
|
+
* // ...
|
|
25
|
+
* const outputDir = await rec.stop('./reports/recordings/my-run');
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn } from 'child_process';
|
|
29
|
+
import fs from 'fs';
|
|
30
|
+
import path from 'path';
|
|
31
|
+
import http from 'http';
|
|
32
|
+
|
|
33
|
+
// ── Polling Recorder (no extra dependencies) ──────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export class PollingRecorder {
|
|
36
|
+
/**
|
|
37
|
+
* @param {import('../adapters/browser.js').CdpBrowserAdapter} browser
|
|
38
|
+
* @param {{ intervalMs?: number, quality?: number }} [options]
|
|
39
|
+
*/
|
|
40
|
+
constructor(browser, options = {}) {
|
|
41
|
+
this._browser = browser;
|
|
42
|
+
this._intervalMs = options.intervalMs ?? 500;
|
|
43
|
+
this._quality = options.quality ?? 70;
|
|
44
|
+
this._frames = [];
|
|
45
|
+
this._timer = null;
|
|
46
|
+
this._startedAt = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Begin periodic screenshot capture. */
|
|
50
|
+
start() {
|
|
51
|
+
if (this._timer) return;
|
|
52
|
+
this._startedAt = Date.now();
|
|
53
|
+
this._frames = [];
|
|
54
|
+
this._timer = setInterval(async () => {
|
|
55
|
+
try {
|
|
56
|
+
const raw = await this._browser.screenshot({ quality: this._quality });
|
|
57
|
+
// screenshot() returns an MCP result — extract base64 data if wrapped
|
|
58
|
+
const b64 = typeof raw === 'object' ? (raw.result ?? raw.data ?? raw) : raw;
|
|
59
|
+
if (b64) this._frames.push({ ts: Date.now(), data: b64 });
|
|
60
|
+
} catch { /* ignore individual capture errors */ }
|
|
61
|
+
}, this._intervalMs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stop recording and save frames to outputDir.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} outputDir - Directory to write frame-NNN.jpg files into
|
|
68
|
+
* @returns {Promise<{ outputDir: string, frameCount: number, durationMs: number }>}
|
|
69
|
+
*/
|
|
70
|
+
async stop(outputDir) {
|
|
71
|
+
if (this._timer) {
|
|
72
|
+
clearInterval(this._timer);
|
|
73
|
+
this._timer = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const durationMs = Date.now() - (this._startedAt ?? Date.now());
|
|
77
|
+
const frames = this._frames.slice();
|
|
78
|
+
this._frames = [];
|
|
79
|
+
|
|
80
|
+
const resolved = path.resolve(outputDir);
|
|
81
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < frames.length; i++) {
|
|
84
|
+
const name = `frame-${String(i).padStart(4, '0')}.jpg`;
|
|
85
|
+
const filePath = path.join(resolved, name);
|
|
86
|
+
const b64 = frames[i].data;
|
|
87
|
+
// b64 may be a data URL prefix — strip it
|
|
88
|
+
const raw = typeof b64 === 'string'
|
|
89
|
+
? b64.replace(/^data:image\/\w+;base64,/, '')
|
|
90
|
+
: b64;
|
|
91
|
+
try {
|
|
92
|
+
fs.writeFileSync(filePath, Buffer.from(raw, 'base64'));
|
|
93
|
+
} catch { /* skip unwritable frames */ }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Write metadata + ffmpeg hint
|
|
97
|
+
const meta = {
|
|
98
|
+
frameCount: frames.length,
|
|
99
|
+
durationMs,
|
|
100
|
+
intervalMs: this._intervalMs,
|
|
101
|
+
capturedAt: new Date(this._startedAt).toISOString(),
|
|
102
|
+
ffmpegHint: `ffmpeg -framerate ${Math.round(1000 / this._intervalMs)} -i frame-%04d.jpg -c:v libx264 -pix_fmt yuv420p recording.mp4`,
|
|
103
|
+
};
|
|
104
|
+
fs.writeFileSync(path.join(resolved, 'meta.json'), JSON.stringify(meta, null, 2));
|
|
105
|
+
|
|
106
|
+
// Run ffmpeg automatically if available
|
|
107
|
+
await _tryFfmpeg(resolved, meta.intervalMs).catch(() => {});
|
|
108
|
+
|
|
109
|
+
return { outputDir: resolved, frameCount: frames.length, durationMs };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── CDP Screencast Recorder (requires ws package) ─────────────────────────────
|
|
114
|
+
|
|
115
|
+
export class CdpScreenRecorder {
|
|
116
|
+
constructor(ws, sessionId) {
|
|
117
|
+
this._ws = ws;
|
|
118
|
+
this._sessionId = sessionId;
|
|
119
|
+
this._frames = [];
|
|
120
|
+
this._startedAt = null;
|
|
121
|
+
this._msgId = 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create a CdpScreenRecorder connected to the first Chrome tab.
|
|
126
|
+
* Requires the `ws` package: npm install ws
|
|
127
|
+
*
|
|
128
|
+
* @param {number} [port=9222]
|
|
129
|
+
* @returns {Promise<CdpScreenRecorder>}
|
|
130
|
+
*/
|
|
131
|
+
static async create(port = 9222) {
|
|
132
|
+
let WebSocket;
|
|
133
|
+
try {
|
|
134
|
+
WebSocket = (await import('ws')).default;
|
|
135
|
+
} catch {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'CDP screencast requires the ws package:\n' +
|
|
138
|
+
' npm install ws\n' +
|
|
139
|
+
'Or use PollingRecorder instead (no extra deps).'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const debuggerUrl = await _fetchDebuggerUrl(port);
|
|
144
|
+
const ws = new WebSocket(debuggerUrl);
|
|
145
|
+
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
ws.once('open', resolve);
|
|
148
|
+
ws.once('error', reject);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const rec = new CdpScreenRecorder(ws, null);
|
|
152
|
+
ws.on('message', data => {
|
|
153
|
+
try {
|
|
154
|
+
const msg = JSON.parse(data.toString());
|
|
155
|
+
if (msg.method === 'Page.screencastFrame') {
|
|
156
|
+
rec._frames.push({ ts: Date.now(), data: msg.params.data });
|
|
157
|
+
// Acknowledge the frame to keep Chrome sending
|
|
158
|
+
rec._send('Page.screencastFrameAck', { sessionId: msg.params.metadata.sessionId });
|
|
159
|
+
}
|
|
160
|
+
} catch { /* ignore parse errors */ }
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return rec;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_send(method, params = {}) {
|
|
167
|
+
this._ws.send(JSON.stringify({ id: this._msgId++, method, params }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Start CDP screencast. */
|
|
171
|
+
async start() {
|
|
172
|
+
this._startedAt = Date.now();
|
|
173
|
+
this._frames = [];
|
|
174
|
+
this._send('Page.startScreencast', {
|
|
175
|
+
format: 'jpeg',
|
|
176
|
+
quality: 70,
|
|
177
|
+
maxWidth: 1280,
|
|
178
|
+
maxHeight: 900,
|
|
179
|
+
everyNthFrame: 1,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Stop screencast and save frames to outputDir.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} outputDir
|
|
187
|
+
* @returns {Promise<{ outputDir: string, frameCount: number, durationMs: number }>}
|
|
188
|
+
*/
|
|
189
|
+
async stop(outputDir) {
|
|
190
|
+
this._send('Page.stopScreencast');
|
|
191
|
+
await new Promise(r => setTimeout(r, 200)); // drain any buffered frames
|
|
192
|
+
this._ws.close();
|
|
193
|
+
|
|
194
|
+
const durationMs = Date.now() - (this._startedAt ?? Date.now());
|
|
195
|
+
const frames = this._frames.slice();
|
|
196
|
+
const resolved = path.resolve(outputDir);
|
|
197
|
+
|
|
198
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
199
|
+
|
|
200
|
+
for (let i = 0; i < frames.length; i++) {
|
|
201
|
+
const name = `frame-${String(i).padStart(4, '0')}.jpg`;
|
|
202
|
+
fs.writeFileSync(path.join(resolved, name), Buffer.from(frames[i].data, 'base64'));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const meta = {
|
|
206
|
+
frameCount: frames.length,
|
|
207
|
+
durationMs,
|
|
208
|
+
capturedAt: new Date(this._startedAt).toISOString(),
|
|
209
|
+
ffmpegHint: `ffmpeg -framerate 2 -i frame-%04d.jpg -c:v libx264 -pix_fmt yuv420p recording.mp4`,
|
|
210
|
+
};
|
|
211
|
+
fs.writeFileSync(path.join(resolved, 'meta.json'), JSON.stringify(meta, null, 2));
|
|
212
|
+
|
|
213
|
+
await _tryFfmpeg(resolved, 500).catch(() => {});
|
|
214
|
+
|
|
215
|
+
return { outputDir: resolved, frameCount: frames.length, durationMs };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function _fetchDebuggerUrl(port) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
http.get(`http://localhost:${port}/json/list`, res => {
|
|
224
|
+
let body = '';
|
|
225
|
+
res.on('data', d => { body += d; });
|
|
226
|
+
res.on('end', () => {
|
|
227
|
+
try {
|
|
228
|
+
const pages = JSON.parse(body);
|
|
229
|
+
const target = pages.find(p => p.type === 'page') ?? pages[0];
|
|
230
|
+
if (!target?.webSocketDebuggerUrl) reject(new Error('No debuggable Chrome page found'));
|
|
231
|
+
else resolve(target.webSocketDebuggerUrl);
|
|
232
|
+
} catch (e) { reject(e); }
|
|
233
|
+
});
|
|
234
|
+
}).on('error', reject);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _tryFfmpeg(outputDir, intervalMs) {
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
const fps = Math.max(1, Math.round(1000 / intervalMs));
|
|
241
|
+
const proc = spawn('ffmpeg', [
|
|
242
|
+
'-y', '-framerate', String(fps),
|
|
243
|
+
'-i', 'frame-%04d.jpg',
|
|
244
|
+
'-c:v', 'libx264', '-pix_fmt', 'yuv420p',
|
|
245
|
+
'recording.mp4',
|
|
246
|
+
], { cwd: outputDir, stdio: 'ignore' });
|
|
247
|
+
proc.on('close', code => (code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`))));
|
|
248
|
+
proc.on('error', reject);
|
|
249
|
+
});
|
|
250
|
+
}
|