@reshotdev/screenshot 0.0.1-beta.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 +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- package/web/subtitle-editor/index.html +295 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
// record-cdp.js - CDP connection utilities for record command
|
|
2
|
+
const { chromium } = require("playwright");
|
|
3
|
+
const chalk = require("chalk");
|
|
4
|
+
const http = require("http");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if Chrome CDP endpoint is available
|
|
8
|
+
* @param {string} host
|
|
9
|
+
* @param {number} port
|
|
10
|
+
* @returns {Promise<{available: boolean, targets?: any[], error?: string}>}
|
|
11
|
+
*/
|
|
12
|
+
async function checkCdpEndpoint(host = "localhost", port = 9222) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const url = `http://${host}:${port}/json/version`;
|
|
15
|
+
|
|
16
|
+
const req = http.get(url, { timeout: 3000 }, (res) => {
|
|
17
|
+
let data = "";
|
|
18
|
+
res.on("data", (chunk) => (data += chunk));
|
|
19
|
+
res.on("end", () => {
|
|
20
|
+
try {
|
|
21
|
+
const info = JSON.parse(data);
|
|
22
|
+
resolve({ available: true, info });
|
|
23
|
+
} catch (e) {
|
|
24
|
+
resolve({
|
|
25
|
+
available: false,
|
|
26
|
+
error: "Invalid response from CDP endpoint",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
req.on("error", (err) => {
|
|
33
|
+
resolve({ available: false, error: err.message });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
req.on("timeout", () => {
|
|
37
|
+
req.destroy();
|
|
38
|
+
resolve({ available: false, error: "Connection timeout" });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get list of available pages from CDP
|
|
45
|
+
* @param {string} host
|
|
46
|
+
* @param {number} port
|
|
47
|
+
* @returns {Promise<any[]>}
|
|
48
|
+
*/
|
|
49
|
+
async function getCdpTargets(host = "localhost", port = 9222) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const url = `http://${host}:${port}/json/list`;
|
|
52
|
+
|
|
53
|
+
const req = http.get(url, { timeout: 3000 }, (res) => {
|
|
54
|
+
let data = "";
|
|
55
|
+
res.on("data", (chunk) => (data += chunk));
|
|
56
|
+
res.on("end", () => {
|
|
57
|
+
try {
|
|
58
|
+
const targets = JSON.parse(data);
|
|
59
|
+
resolve(targets);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
reject(new Error("Failed to parse CDP targets"));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
req.on("error", reject);
|
|
67
|
+
req.on("timeout", () => {
|
|
68
|
+
req.destroy();
|
|
69
|
+
reject(new Error("Timeout getting CDP targets"));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Connect to an active Chrome instance via CDP with enhanced error handling
|
|
76
|
+
* @param {Object} options - Connection options
|
|
77
|
+
* @param {boolean} options.autoLaunch - Whether to auto-launch Chrome if not running (not implemented yet)
|
|
78
|
+
* @param {boolean} options.uiMode - Whether we're in UI mode (affects error messages)
|
|
79
|
+
* @param {string} options.targetUrl - Specific URL to connect to (optional)
|
|
80
|
+
* @param {string} options.targetId - Specific target ID to connect to (optional)
|
|
81
|
+
* @returns {Promise<{browser, context, page}>} Connected browser objects
|
|
82
|
+
*/
|
|
83
|
+
async function connectToActivePage(options = {}) {
|
|
84
|
+
const { uiMode = false, targetUrl = null, targetId = null } = options;
|
|
85
|
+
const host = "localhost";
|
|
86
|
+
const port = 9222;
|
|
87
|
+
const cdpUrl = `http://${host}:${port}`;
|
|
88
|
+
|
|
89
|
+
// Step 1: Check if CDP endpoint is reachable
|
|
90
|
+
console.log(
|
|
91
|
+
chalk.gray(`[CDP] Checking if Chrome is available at ${cdpUrl}...`)
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const endpointCheck = await checkCdpEndpoint(host, port);
|
|
95
|
+
|
|
96
|
+
if (!endpointCheck.available) {
|
|
97
|
+
console.error(
|
|
98
|
+
chalk.red(
|
|
99
|
+
`\n❌ Chrome CDP endpoint not reachable: ${endpointCheck.error}`
|
|
100
|
+
)
|
|
101
|
+
);
|
|
102
|
+
printChromeInstructions();
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Chrome is not running with remote debugging enabled. ${endpointCheck.error}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log(
|
|
109
|
+
chalk.gray(
|
|
110
|
+
`[CDP] Chrome found: ${endpointCheck.info?.Browser || "Unknown browser"}`
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Step 2: Get available targets
|
|
115
|
+
let targets;
|
|
116
|
+
try {
|
|
117
|
+
targets = await getCdpTargets(host, port);
|
|
118
|
+
console.log(chalk.gray(`[CDP] Found ${targets.length} browser targets`));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(
|
|
121
|
+
chalk.red(`\n❌ Failed to get browser targets: ${error.message}`)
|
|
122
|
+
);
|
|
123
|
+
throw new Error(`Failed to enumerate browser tabs: ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Step 3: Find a suitable page target
|
|
127
|
+
const pageTargets = targets.filter((t) => t.type === "page");
|
|
128
|
+
|
|
129
|
+
if (pageTargets.length === 0) {
|
|
130
|
+
console.error(chalk.red("\n❌ No open tabs found in Chrome"));
|
|
131
|
+
console.log(
|
|
132
|
+
chalk.yellow("Please open at least one tab in Chrome and try again.\n")
|
|
133
|
+
);
|
|
134
|
+
throw new Error("No open tabs found in Chrome. Please open a tab first.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Find the target based on options
|
|
138
|
+
let bestTarget;
|
|
139
|
+
|
|
140
|
+
if (targetId) {
|
|
141
|
+
// Find by target ID
|
|
142
|
+
bestTarget = pageTargets.find((t) => t.id === targetId);
|
|
143
|
+
if (!bestTarget) {
|
|
144
|
+
console.error(chalk.red(`\n❌ Target with ID ${targetId} not found`));
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Target with ID ${targetId} not found. It may have been closed.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
console.log(
|
|
150
|
+
chalk.gray(
|
|
151
|
+
`[CDP] Using specified target: ${bestTarget.title || bestTarget.url}`
|
|
152
|
+
)
|
|
153
|
+
);
|
|
154
|
+
} else if (targetUrl) {
|
|
155
|
+
// Find by URL (partial match)
|
|
156
|
+
bestTarget = pageTargets.find((t) => t.url.includes(targetUrl));
|
|
157
|
+
if (!bestTarget) {
|
|
158
|
+
// Try more flexible matching
|
|
159
|
+
const targetUrlLower = targetUrl.toLowerCase();
|
|
160
|
+
bestTarget = pageTargets.find((t) =>
|
|
161
|
+
t.url.toLowerCase().includes(targetUrlLower)
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (!bestTarget) {
|
|
165
|
+
console.error(chalk.red(`\n❌ No tab found matching URL: ${targetUrl}`));
|
|
166
|
+
console.log(chalk.yellow("Available tabs:"));
|
|
167
|
+
pageTargets.forEach((t) => console.log(chalk.gray(` - ${t.url}`)));
|
|
168
|
+
throw new Error(`No tab found matching URL: ${targetUrl}`);
|
|
169
|
+
}
|
|
170
|
+
console.log(
|
|
171
|
+
chalk.gray(
|
|
172
|
+
`[CDP] Found target matching URL: ${bestTarget.title || bestTarget.url}`
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
// Default: Sort and pick best target (prefer non-chrome:// URLs, exclude localhost:4300 which is our UI)
|
|
177
|
+
const sortedTargets = [...pageTargets].sort((a, b) => {
|
|
178
|
+
// Exclude our own UI
|
|
179
|
+
const aIsOurUI =
|
|
180
|
+
a.url.includes("localhost:4300") || a.url.includes("127.0.0.1:4300");
|
|
181
|
+
const bIsOurUI =
|
|
182
|
+
b.url.includes("localhost:4300") || b.url.includes("127.0.0.1:4300");
|
|
183
|
+
if (aIsOurUI && !bIsOurUI) return 1;
|
|
184
|
+
if (!aIsOurUI && bIsOurUI) return -1;
|
|
185
|
+
|
|
186
|
+
// Prefer real pages over chrome:// pages
|
|
187
|
+
const aIsChrome =
|
|
188
|
+
a.url.startsWith("chrome://") ||
|
|
189
|
+
a.url.startsWith("chrome-error://") ||
|
|
190
|
+
a.url === "about:blank";
|
|
191
|
+
const bIsChrome =
|
|
192
|
+
b.url.startsWith("chrome://") ||
|
|
193
|
+
b.url.startsWith("chrome-error://") ||
|
|
194
|
+
b.url === "about:blank";
|
|
195
|
+
if (aIsChrome && !bIsChrome) return 1;
|
|
196
|
+
if (!aIsChrome && bIsChrome) return -1;
|
|
197
|
+
return 0;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
bestTarget = sortedTargets[0];
|
|
201
|
+
console.log(
|
|
202
|
+
chalk.gray(`[CDP] Best target: ${bestTarget.title || bestTarget.url}`)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Warn if best target is still a chrome:// page or our UI
|
|
207
|
+
if (
|
|
208
|
+
bestTarget.url.startsWith("chrome://") ||
|
|
209
|
+
bestTarget.url.startsWith("chrome-error://") ||
|
|
210
|
+
bestTarget.url === "about:blank"
|
|
211
|
+
) {
|
|
212
|
+
console.log(
|
|
213
|
+
chalk.yellow("\n⚠ Warning: Chrome is not on a regular webpage.")
|
|
214
|
+
);
|
|
215
|
+
console.log(
|
|
216
|
+
chalk.yellow(
|
|
217
|
+
" Please navigate to your application in Chrome before starting recording.\n"
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
bestTarget.url.includes("localhost:4300") ||
|
|
224
|
+
bestTarget.url.includes("127.0.0.1:4300")
|
|
225
|
+
) {
|
|
226
|
+
console.log(
|
|
227
|
+
chalk.yellow("\n⚠ Warning: Selected tab is the Reshot UI itself!")
|
|
228
|
+
);
|
|
229
|
+
console.log(
|
|
230
|
+
chalk.yellow(
|
|
231
|
+
" Please select a different tab with your target application.\n"
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Step 4: Connect via Playwright CDP
|
|
237
|
+
try {
|
|
238
|
+
console.log(chalk.gray(`[CDP] Connecting via Playwright...`));
|
|
239
|
+
|
|
240
|
+
const browser = await chromium.connectOverCDP(cdpUrl, {
|
|
241
|
+
timeout: 10000, // 10 second timeout
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const contexts = browser.contexts();
|
|
245
|
+
|
|
246
|
+
if (contexts.length === 0) {
|
|
247
|
+
// This shouldn't happen if we found targets above, but handle it anyway
|
|
248
|
+
console.error(
|
|
249
|
+
chalk.red("\n❌ Connected but no browser contexts available")
|
|
250
|
+
);
|
|
251
|
+
await browser.close();
|
|
252
|
+
throw new Error("No browser contexts found after connecting");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const context = contexts[0];
|
|
256
|
+
const pages = context.pages();
|
|
257
|
+
|
|
258
|
+
if (pages.length === 0) {
|
|
259
|
+
console.error(chalk.red("\n❌ Connected but no pages available"));
|
|
260
|
+
await browser.close();
|
|
261
|
+
throw new Error("No pages found in browser context");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Find the page matching our best target
|
|
265
|
+
let page = null;
|
|
266
|
+
|
|
267
|
+
// First, try exact URL match
|
|
268
|
+
for (const p of pages) {
|
|
269
|
+
if (p.url() === bestTarget.url) {
|
|
270
|
+
page = p;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// If no exact match, try partial match
|
|
276
|
+
if (!page && (targetUrl || targetId)) {
|
|
277
|
+
for (const p of pages) {
|
|
278
|
+
const pUrl = p.url();
|
|
279
|
+
if (targetUrl && pUrl.includes(targetUrl)) {
|
|
280
|
+
page = p;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Fallback: find any non-chrome:// page that's not our UI
|
|
287
|
+
if (!page) {
|
|
288
|
+
for (const p of pages) {
|
|
289
|
+
const pUrl = p.url();
|
|
290
|
+
const isOurUI =
|
|
291
|
+
pUrl.includes("localhost:4300") || pUrl.includes("127.0.0.1:4300");
|
|
292
|
+
const isChrome =
|
|
293
|
+
pUrl.startsWith("chrome://") ||
|
|
294
|
+
pUrl.startsWith("chrome-error://") ||
|
|
295
|
+
pUrl === "about:blank";
|
|
296
|
+
if (!isOurUI && !isChrome) {
|
|
297
|
+
page = p;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Last resort: just use the last page
|
|
304
|
+
if (!page) {
|
|
305
|
+
page = pages[pages.length - 1];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
console.log(chalk.green(`✔ Connected to Chrome`));
|
|
309
|
+
console.log(chalk.gray(` Active page: ${page.url()}\n`));
|
|
310
|
+
|
|
311
|
+
return { browser, context, page };
|
|
312
|
+
} catch (error) {
|
|
313
|
+
// Enhanced error handling for Playwright connection errors
|
|
314
|
+
const errorMsg = error.message || String(error);
|
|
315
|
+
|
|
316
|
+
if (
|
|
317
|
+
errorMsg.includes("Target closed") ||
|
|
318
|
+
errorMsg.includes("Target page, context or browser has been closed")
|
|
319
|
+
) {
|
|
320
|
+
console.error(chalk.red("\n❌ Browser tab was closed during connection"));
|
|
321
|
+
throw new Error(
|
|
322
|
+
"Browser tab was closed. Please keep a tab open and try again."
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (errorMsg.includes("timeout") || errorMsg.includes("Timeout")) {
|
|
327
|
+
console.error(chalk.red("\n❌ Connection timeout"));
|
|
328
|
+
console.log(
|
|
329
|
+
chalk.yellow(
|
|
330
|
+
"Chrome may be unresponsive. Try restarting Chrome with the debug flag.\n"
|
|
331
|
+
)
|
|
332
|
+
);
|
|
333
|
+
printChromeInstructions();
|
|
334
|
+
throw new Error("Connection timeout. Chrome may be unresponsive.");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
console.error(chalk.red(`\n❌ Failed to connect: ${errorMsg}`));
|
|
338
|
+
printChromeInstructions();
|
|
339
|
+
throw new Error(`Failed to connect to Chrome: ${errorMsg}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Print instructions for starting Chrome with remote debugging
|
|
345
|
+
*/
|
|
346
|
+
function printChromeInstructions() {
|
|
347
|
+
const platform = process.platform;
|
|
348
|
+
|
|
349
|
+
console.log(
|
|
350
|
+
chalk.yellow(
|
|
351
|
+
"\nTo enable recording, Chrome needs to be started with remote debugging:"
|
|
352
|
+
)
|
|
353
|
+
);
|
|
354
|
+
console.log(
|
|
355
|
+
chalk.yellow(
|
|
356
|
+
"1. Quit Chrome completely (check that no Chrome processes are running)"
|
|
357
|
+
)
|
|
358
|
+
);
|
|
359
|
+
console.log(
|
|
360
|
+
chalk.yellow("2. Start Chrome from terminal with the debug flag:\n")
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
if (platform === "darwin") {
|
|
364
|
+
console.log(chalk.cyan(" macOS:"));
|
|
365
|
+
console.log(
|
|
366
|
+
chalk.white(
|
|
367
|
+
' /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.reshot/chrome-debug"\n'
|
|
368
|
+
)
|
|
369
|
+
);
|
|
370
|
+
} else if (platform === "win32") {
|
|
371
|
+
console.log(chalk.cyan(" Windows:"));
|
|
372
|
+
console.log(
|
|
373
|
+
chalk.white(
|
|
374
|
+
' "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.reshot\\chrome-debug"\n'
|
|
375
|
+
)
|
|
376
|
+
);
|
|
377
|
+
} else {
|
|
378
|
+
console.log(chalk.cyan(" Linux:"));
|
|
379
|
+
console.log(
|
|
380
|
+
chalk.white(
|
|
381
|
+
' google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.reshot/chrome-debug"'
|
|
382
|
+
)
|
|
383
|
+
);
|
|
384
|
+
console.log(chalk.gray(" or"));
|
|
385
|
+
console.log(
|
|
386
|
+
chalk.white(
|
|
387
|
+
' chromium --remote-debugging-port=9222 --user-data-dir="$HOME/.reshot/chrome-debug"\n'
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
console.log(
|
|
393
|
+
chalk.gray("3. Navigate to your application in the opened Chrome window")
|
|
394
|
+
);
|
|
395
|
+
console.log(chalk.gray('4. Come back here and click "Start Recording"\n'));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Save the current browser session state (cookies, localStorage) to a file
|
|
400
|
+
* This can be used later by capture-engine to maintain authenticated sessions
|
|
401
|
+
* @param {string} outputPath - Path to save the storage state JSON
|
|
402
|
+
* @returns {Promise<{success: boolean, path?: string, error?: string}>}
|
|
403
|
+
*/
|
|
404
|
+
async function saveSessionState(outputPath) {
|
|
405
|
+
try {
|
|
406
|
+
const { browser, context, page } = await connectToActivePage({ uiMode: true });
|
|
407
|
+
|
|
408
|
+
if (!context) {
|
|
409
|
+
return { success: false, error: "Could not connect to browser context" };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Playwright's storageState includes cookies and localStorage
|
|
413
|
+
const rawState = await context.storageState();
|
|
414
|
+
|
|
415
|
+
// Sanitize cookies (CDP returns expires: -1 for session cookies, which Playwright rejects)
|
|
416
|
+
const { sanitized: storageState, stats } = sanitizeStorageState(rawState);
|
|
417
|
+
if (stats.fixed > 0 || stats.removed > 0 || stats.stripped > 0) {
|
|
418
|
+
console.log(chalk.gray(` Sanitized cookies: ${stats.fixed} fixed, ${stats.removed} removed, ${stats.stripped} stripped`));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Ensure directory exists
|
|
422
|
+
const fs = require("fs-extra");
|
|
423
|
+
const path = require("path");
|
|
424
|
+
fs.ensureDirSync(path.dirname(outputPath));
|
|
425
|
+
|
|
426
|
+
// Write storage state to file
|
|
427
|
+
fs.writeJsonSync(outputPath, storageState, { spaces: 2 });
|
|
428
|
+
|
|
429
|
+
console.log(chalk.green(` ✔ Session saved to: ${outputPath}`));
|
|
430
|
+
console.log(chalk.gray(` Cookies: ${storageState.cookies?.length || 0}`));
|
|
431
|
+
console.log(chalk.gray(` Origins with localStorage: ${storageState.origins?.length || 0}`));
|
|
432
|
+
|
|
433
|
+
// Don't close the browser - user might still be using it
|
|
434
|
+
// await browser.close();
|
|
435
|
+
|
|
436
|
+
return { success: true, path: outputPath };
|
|
437
|
+
} catch (error) {
|
|
438
|
+
console.error(chalk.red(` ✖ Failed to save session: ${error.message}`));
|
|
439
|
+
return { success: false, error: error.message };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Get the default session state file path
|
|
445
|
+
* @returns {string}
|
|
446
|
+
*/
|
|
447
|
+
function getDefaultSessionPath() {
|
|
448
|
+
const path = require("path");
|
|
449
|
+
const os = require("os");
|
|
450
|
+
return path.join(os.homedir(), ".reshot", "session-state.json");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Quietly check if CDP is available and sync the session if so.
|
|
455
|
+
* This is called automatically before captures to use the live browser session.
|
|
456
|
+
* @param {string} outputPath - Path to save the storage state JSON (optional, uses default)
|
|
457
|
+
* @param {Function} logger - Optional logging function (uses console.log if not provided)
|
|
458
|
+
* @returns {Promise<{synced: boolean, path?: string, error?: string}>}
|
|
459
|
+
*/
|
|
460
|
+
async function autoSyncSessionFromCDP(outputPath = null, logger = null) {
|
|
461
|
+
const log = logger || (() => {}); // Quiet by default
|
|
462
|
+
const fs = require("fs-extra");
|
|
463
|
+
const path = require("path");
|
|
464
|
+
const os = require("os");
|
|
465
|
+
|
|
466
|
+
const sessionPath = outputPath || path.join(os.homedir(), ".reshot", "session-state.json");
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
// Step 1: Check if CDP endpoint is available (quietly)
|
|
470
|
+
const endpointCheck = await checkCdpEndpoint("localhost", 9222);
|
|
471
|
+
|
|
472
|
+
if (!endpointCheck.available) {
|
|
473
|
+
// No CDP browser - that's fine, just return
|
|
474
|
+
log(chalk.gray(" → No CDP browser detected, skipping session sync"));
|
|
475
|
+
return { synced: false, reason: "no_cdp" };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
log(chalk.gray(" → CDP browser detected, syncing session..."));
|
|
479
|
+
|
|
480
|
+
// Step 2: Connect to the browser
|
|
481
|
+
const browser = await chromium.connectOverCDP("http://localhost:9222", {
|
|
482
|
+
timeout: 5000,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
const contexts = browser.contexts();
|
|
487
|
+
if (contexts.length === 0) {
|
|
488
|
+
log(chalk.gray(" → No browser contexts found"));
|
|
489
|
+
await browser.close().catch(() => {}); // Disconnect (won't close Chrome since we used connectOverCDP)
|
|
490
|
+
return { synced: false, reason: "no_context" };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const context = contexts[0];
|
|
494
|
+
|
|
495
|
+
// Step 3: Get storage state
|
|
496
|
+
const rawState = await context.storageState();
|
|
497
|
+
|
|
498
|
+
// Sanitize cookies (CDP returns expires: -1 for session cookies, which Playwright rejects)
|
|
499
|
+
const { sanitized: storageState, stats } = sanitizeStorageState(rawState);
|
|
500
|
+
if (stats.fixed > 0 || stats.removed > 0 || stats.stripped > 0) {
|
|
501
|
+
log(chalk.gray(` Sanitized cookies: ${stats.fixed} fixed, ${stats.removed} removed, ${stats.stripped} stripped`));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Check if there's actually any meaningful session data
|
|
505
|
+
const hasCookies = storageState.cookies && storageState.cookies.length > 0;
|
|
506
|
+
const hasStorage = storageState.origins && storageState.origins.length > 0;
|
|
507
|
+
|
|
508
|
+
// Disconnect from CDP (browser.close() on CDP connection just disconnects, doesn't close Chrome)
|
|
509
|
+
await browser.close().catch(() => {});
|
|
510
|
+
|
|
511
|
+
if (!hasCookies && !hasStorage) {
|
|
512
|
+
log(chalk.gray(" → No session data found in CDP browser"));
|
|
513
|
+
return { synced: false, reason: "empty_session" };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Step 4: Save to file
|
|
517
|
+
fs.ensureDirSync(path.dirname(sessionPath));
|
|
518
|
+
fs.writeJsonSync(sessionPath, storageState, { spaces: 2 });
|
|
519
|
+
|
|
520
|
+
log(chalk.green(` ✔ Auto-synced session from CDP browser`));
|
|
521
|
+
log(chalk.gray(` Cookies: ${storageState.cookies?.length || 0}, localStorage origins: ${storageState.origins?.length || 0}`));
|
|
522
|
+
|
|
523
|
+
return { synced: true, path: sessionPath };
|
|
524
|
+
} catch (innerError) {
|
|
525
|
+
// Make sure to disconnect even on error
|
|
526
|
+
await browser.close().catch(() => {});
|
|
527
|
+
throw innerError;
|
|
528
|
+
}
|
|
529
|
+
} catch (error) {
|
|
530
|
+
// Silently fail - this is a convenience feature
|
|
531
|
+
log(chalk.gray(` → Session sync skipped: ${error.message}`));
|
|
532
|
+
return { synced: false, reason: "error", error: error.message };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Fields that Playwright's `Storage.setCookies` accepts.
|
|
538
|
+
* CDP returns extra metadata (e.g. `partitionKey`, `_crHasCrossSiteAncestor`)
|
|
539
|
+
* that Playwright rejects as "Invalid cookie fields".
|
|
540
|
+
*/
|
|
541
|
+
const VALID_COOKIE_FIELDS = new Set([
|
|
542
|
+
'name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite',
|
|
543
|
+
]);
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Sanitize a Playwright storage state object so it can be safely passed
|
|
547
|
+
* to `browser.newContext({ storageState })`.
|
|
548
|
+
*
|
|
549
|
+
* - Strips any cookie field not in the Playwright whitelist (catches CDP-only
|
|
550
|
+
* metadata like `partitionKey`, `_crHasCrossSiteAncestor`, etc.)
|
|
551
|
+
* - Removes `expires` when ≤ 0 (CDP session cookies)
|
|
552
|
+
* - Drops cookies missing `name` or `domain`
|
|
553
|
+
*
|
|
554
|
+
* @param {Object} storageState - Raw Playwright storage state (`{ cookies, origins }`)
|
|
555
|
+
* @returns {{ sanitized: Object, stats: { fixed: number, removed: number, stripped: number } }}
|
|
556
|
+
*/
|
|
557
|
+
function sanitizeStorageState(storageState) {
|
|
558
|
+
if (!storageState || !storageState.cookies) {
|
|
559
|
+
return { sanitized: storageState, stats: { fixed: 0, removed: 0, stripped: 0 } };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
let fixed = 0;
|
|
563
|
+
let removed = 0;
|
|
564
|
+
let stripped = 0;
|
|
565
|
+
|
|
566
|
+
const cleanCookies = [];
|
|
567
|
+
for (const cookie of storageState.cookies) {
|
|
568
|
+
// Remove cookies missing required fields (value can be empty string)
|
|
569
|
+
if (!cookie.name || !cookie.domain) {
|
|
570
|
+
removed++;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Strip unknown fields (CDP metadata Playwright doesn't recognize)
|
|
575
|
+
const clean = {};
|
|
576
|
+
let hadUnknown = false;
|
|
577
|
+
for (const [key, val] of Object.entries(cookie)) {
|
|
578
|
+
if (VALID_COOKIE_FIELDS.has(key)) {
|
|
579
|
+
clean[key] = val;
|
|
580
|
+
} else {
|
|
581
|
+
hadUnknown = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (hadUnknown) {
|
|
585
|
+
stripped++;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Fix invalid expires values (session cookies from CDP)
|
|
589
|
+
if (clean.expires !== undefined && clean.expires <= 0) {
|
|
590
|
+
fixed++;
|
|
591
|
+
delete clean.expires;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
cleanCookies.push(clean);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
sanitized: { ...storageState, cookies: cleanCookies },
|
|
599
|
+
stats: { fixed, removed, stripped },
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
module.exports = {
|
|
604
|
+
connectToActivePage,
|
|
605
|
+
checkCdpEndpoint,
|
|
606
|
+
getCdpTargets,
|
|
607
|
+
printChromeInstructions,
|
|
608
|
+
saveSessionState,
|
|
609
|
+
getDefaultSessionPath,
|
|
610
|
+
autoSyncSessionFromCDP,
|
|
611
|
+
sanitizeStorageState,
|
|
612
|
+
};
|