@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.
Files changed (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. package/web/subtitle-editor/index.html +295 -0
@@ -0,0 +1,360 @@
1
+ // record-screenshot.js - Screenshot capture flow with cropping
2
+ const chalk = require("chalk");
3
+ const inquirer = require("inquirer");
4
+ const path = require("path");
5
+ const fs = require("fs-extra");
6
+ const express = require("express");
7
+ const { updateBrowserMode } = require("./record-browser-injection");
8
+ const { saveScenarioProgress } = require("./record-config");
9
+
10
+ /**
11
+ * Start screenshot capture flow
12
+ * @param {Object} sessionState - Recording session state
13
+ * @param {Page} page - Playwright page object
14
+ */
15
+ async function startCaptureFlow(sessionState, page) {
16
+ const { captureType } = await inquirer.prompt([
17
+ {
18
+ type: "list",
19
+ name: "captureType",
20
+ message: "What do you want to capture?",
21
+ choices: [
22
+ { name: "Single-Step Screenshot (PNG)", value: "screenshot" },
23
+ { name: "Multi-Step Animated Clip (GIF/MP4)", value: "clip" },
24
+ ],
25
+ },
26
+ ]);
27
+
28
+ if (captureType === "screenshot") {
29
+ const screenshotStep = await captureScreenshotInteractive(
30
+ sessionState,
31
+ page
32
+ );
33
+ await registerScreenshotStep(sessionState, page, screenshotStep);
34
+ } else if (captureType === "clip") {
35
+ const { startClipRecording } = require("./record-clip");
36
+ await startClipRecording(sessionState, page);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Capture screenshot with optional element selection and cropping
42
+ * @param {Object} sessionState - Recording session state
43
+ * @param {Page} page - Playwright page object
44
+ * @param {Object} options - Additional options
45
+ * @param {boolean} options.uiMode - If true, skip inquirer prompts
46
+ * @param {string} options.areaType - 'full' or 'element' (required if uiMode)
47
+ * @param {string} options.selector - Element selector (if areaType is 'element')
48
+ * @param {string} options.outputFilename - Output filename
49
+ * @param {Object} options.clip - Crop box coordinates
50
+ */
51
+ async function captureScreenshotInteractive(sessionState, page, options = {}) {
52
+ const {
53
+ uiMode = false,
54
+ selector: providedSelector,
55
+ clip: providedClip,
56
+ outputFilename: providedFilename,
57
+ } = options;
58
+ let { areaType: providedAreaType } = options;
59
+
60
+ let areaType = providedAreaType;
61
+
62
+ if (!uiMode) {
63
+ // CLI mode - use inquirer
64
+ const answers = await inquirer.prompt([
65
+ {
66
+ type: "list",
67
+ name: "areaType",
68
+ message: "What area should be captured?",
69
+ choices: [
70
+ { name: "Full Page", value: "full" },
71
+ { name: "Select a Specific Element", value: "element" },
72
+ ],
73
+ },
74
+ ]);
75
+ areaType = answers.areaType;
76
+ } else {
77
+ // UI mode - use provided options or defaults
78
+ areaType = areaType || "full";
79
+ }
80
+
81
+ let selector = providedSelector || null;
82
+
83
+ if (areaType === "element" && !selector) {
84
+ if (uiMode) {
85
+ // In UI mode without selector, default to full page
86
+ console.log(
87
+ chalk.yellow(
88
+ "[Recorder] Element mode without selector, falling back to full page"
89
+ )
90
+ );
91
+ areaType = "full";
92
+ } else {
93
+ // Set mode to element selection
94
+ sessionState.mode = "select-element-for-screenshot";
95
+ sessionState.pendingCapture = { selector: null };
96
+
97
+ await updateBrowserMode(page, "select-element-for-screenshot");
98
+
99
+ console.log(
100
+ chalk.cyan(
101
+ "\n Return to your browser and click on the element you want to capture...\n"
102
+ )
103
+ );
104
+
105
+ // Wait for element to be selected
106
+ await new Promise((resolve) => {
107
+ sessionState.onElementSelected = resolve;
108
+ });
109
+
110
+ selector = sessionState.pendingCapture.selector;
111
+ await updateBrowserMode(page, "normal");
112
+ }
113
+ }
114
+
115
+ // Get output filename
116
+ let outputFilename = providedFilename;
117
+
118
+ if (!outputFilename && !uiMode) {
119
+ const answers = await inquirer.prompt([
120
+ {
121
+ type: "input",
122
+ name: "outputFilename",
123
+ message: "Output filename:",
124
+ default: `${sessionState.visualKey}-view.png`,
125
+ },
126
+ ]);
127
+ outputFilename = answers.outputFilename;
128
+ } else if (!outputFilename) {
129
+ outputFilename = `${sessionState.visualKey}-${Date.now()}.png`;
130
+ }
131
+
132
+ // Capture temporary screenshot
133
+ const tempDir = path.join(process.cwd(), ".reshot", "tmp");
134
+ fs.ensureDirSync(tempDir);
135
+ const tempImagePath = path.join(tempDir, `temp-screenshot-${Date.now()}.png`);
136
+
137
+ if (areaType === "full") {
138
+ await page.screenshot({ path: tempImagePath, fullPage: true });
139
+ } else {
140
+ const element = await page.locator(selector).first();
141
+ await element.screenshot({ path: tempImagePath });
142
+ }
143
+
144
+ console.log(chalk.green(` āœ” Screenshot captured\n`));
145
+
146
+ // Ask about cropping (only in CLI mode unless clip is provided)
147
+ let cropConfig = null;
148
+ let cropBox = providedClip || null;
149
+
150
+ if (!uiMode && !cropBox) {
151
+ const { wantsCrop } = await inquirer.prompt([
152
+ {
153
+ type: "confirm",
154
+ name: "wantsCrop",
155
+ message:
156
+ "Do you want to crop this screenshot (e.g., to focus on a specific modal or element)?",
157
+ default: false,
158
+ },
159
+ ]);
160
+
161
+ if (wantsCrop) {
162
+ cropConfig = await runCropperServer(tempImagePath, page);
163
+
164
+ // Check if the cropper returned the new format or legacy format
165
+ if (cropConfig && cropConfig.region) {
166
+ // New format with full crop config
167
+ console.log(
168
+ chalk.green(
169
+ `\n āœ” Crop defined: ${JSON.stringify(cropConfig.region)}`
170
+ )
171
+ );
172
+ if (cropConfig.persistToScenario) {
173
+ console.log(
174
+ chalk.cyan(
175
+ ` → Will apply to all subsequent captures in this scenario\n`
176
+ )
177
+ );
178
+ } else {
179
+ console.log("");
180
+ }
181
+ // Convert region to clip format for backward compatibility
182
+ cropBox = cropConfig.region;
183
+ } else if (cropConfig && cropConfig.x !== undefined) {
184
+ // Legacy format - just x, y, width, height
185
+ cropBox = cropConfig;
186
+ cropConfig = {
187
+ enabled: true,
188
+ region: cropBox,
189
+ scaleMode: "none",
190
+ preserveAspectRatio: true,
191
+ persistToScenario: false,
192
+ };
193
+ console.log(
194
+ chalk.green(`\n āœ” Crop defined: ${JSON.stringify(cropBox)}\n`)
195
+ );
196
+ }
197
+ }
198
+ }
199
+
200
+ // Clean up temp image
201
+ fs.removeSync(tempImagePath);
202
+
203
+ // Get device scale factor from page
204
+ let deviceScaleFactor = null;
205
+ try {
206
+ deviceScaleFactor = await page.evaluate(() => window.devicePixelRatio || 1);
207
+ } catch (error) {
208
+ // Fallback to 1 if evaluation fails
209
+ deviceScaleFactor = 1;
210
+ }
211
+
212
+ // Generate stable step ID
213
+ const stepId = `${sessionState.visualKey}-${Date.now()}-${Math.random()
214
+ .toString(36)
215
+ .substr(2, 9)}`;
216
+
217
+ // Create screenshot step with full metadata
218
+ const screenshotStep = {
219
+ action: "screenshot",
220
+ key: buildCaptureKey(sessionState.visualKey, outputFilename),
221
+ path: outputFilename,
222
+ id: stepId,
223
+ deviceScaleFactor: deviceScaleFactor !== 1 ? deviceScaleFactor : undefined,
224
+ };
225
+
226
+ if (selector) {
227
+ screenshotStep.selector = selector;
228
+ // Add default selector padding (can be customized later)
229
+ // Default to 10px padding on all sides
230
+ screenshotStep.selectorPadding = {
231
+ top: 10,
232
+ right: 10,
233
+ bottom: 10,
234
+ left: 10,
235
+ };
236
+ }
237
+
238
+ // Handle crop configuration
239
+ if (cropConfig && cropConfig.enabled) {
240
+ // Use the new crop format
241
+ screenshotStep.crop = {
242
+ enabled: true,
243
+ region: cropConfig.region,
244
+ scaleMode: cropConfig.scaleMode || "none",
245
+ preserveAspectRatio: cropConfig.preserveAspectRatio !== false,
246
+ };
247
+
248
+ if (cropConfig.padding) {
249
+ screenshotStep.crop.padding = cropConfig.padding;
250
+ }
251
+
252
+ // Flag to indicate this crop should be persisted to scenario level
253
+ if (cropConfig.persistToScenario) {
254
+ screenshotStep._persistCropToScenario = true;
255
+ }
256
+ } else if (cropBox) {
257
+ // Legacy format - convert to clip
258
+ screenshotStep.clip = cropBox;
259
+ }
260
+
261
+ return screenshotStep;
262
+ }
263
+
264
+ /**
265
+ * Run cropper mini-server with HTML UI
266
+ * @param {string} tempImagePath - Path to temporary screenshot
267
+ * @param {Page} page - Playwright page object for opening cropper UI
268
+ * @returns {Promise<Object>} Crop box coordinates
269
+ */
270
+ async function runCropperServer(tempImagePath, page) {
271
+ return new Promise((resolve, reject) => {
272
+ const app = express();
273
+ app.use(express.json());
274
+
275
+ let cropResult = null;
276
+ let cropperPage = null;
277
+
278
+ // Serve cropper HTML
279
+ app.get("/", (req, res) => {
280
+ const htmlPath = path.join(__dirname, "../../web/cropper/index.html");
281
+ res.sendFile(htmlPath);
282
+ });
283
+
284
+ // Serve temp image
285
+ app.get("/image", (req, res) => {
286
+ res.sendFile(tempImagePath);
287
+ });
288
+
289
+ // Receive crop coordinates
290
+ app.post("/crop", (req, res) => {
291
+ cropResult = req.body;
292
+ res.json({ ok: true });
293
+
294
+ // Close cropper and resolve
295
+ setTimeout(async () => {
296
+ if (cropperPage) {
297
+ await cropperPage.close();
298
+ }
299
+ server.close();
300
+ resolve(cropResult);
301
+ }, 500);
302
+ });
303
+
304
+ // Start server on ephemeral port
305
+ const server = app.listen(0, async () => {
306
+ const port = server.address().port;
307
+ const url = `http://localhost:${port}`;
308
+
309
+ console.log(chalk.cyan(`\n Opening cropper UI at ${url}...\n`));
310
+
311
+ try {
312
+ // Open cropper in new tab
313
+ const context = page.context();
314
+ cropperPage = await context.newPage();
315
+ await cropperPage.goto(url);
316
+ } catch (error) {
317
+ server.close();
318
+ reject(error);
319
+ }
320
+ });
321
+
322
+ // Timeout after 5 minutes
323
+ setTimeout(() => {
324
+ if (!cropResult) {
325
+ server.close();
326
+ if (cropperPage) {
327
+ cropperPage.close();
328
+ }
329
+ reject(new Error("Cropper timeout"));
330
+ }
331
+ }, 5 * 60 * 1000);
332
+ });
333
+ }
334
+
335
+ function buildCaptureKey(visualKey, filename) {
336
+ const base = filename
337
+ .replace(path.extname(filename), "")
338
+ .replace(/[^a-zA-Z0-9-]/g, "-")
339
+ .toLowerCase()
340
+ .replace(/-+/g, "-")
341
+ .replace(/^-|-$/g, "");
342
+ const sanitized = base || `capture-${Date.now()}`;
343
+ return `${visualKey}-${sanitized}`.replace(/-+/g, "-");
344
+ }
345
+
346
+ async function registerScreenshotStep(sessionState, page, screenshotStep) {
347
+ sessionState.capturedSteps.push(screenshotStep);
348
+ await saveScenarioProgress(sessionState, page, { finalize: false });
349
+ console.log(
350
+ chalk.green(
351
+ ` āœ” Screenshot step added: ${sessionState.visualKey}/${screenshotStep.key} → ${screenshotStep.path}\n`
352
+ )
353
+ );
354
+ }
355
+
356
+ module.exports = {
357
+ startCaptureFlow,
358
+ captureScreenshot: captureScreenshotInteractive,
359
+ captureScreenshotInteractive,
360
+ };
@@ -0,0 +1,123 @@
1
+ // record-terminal.js - Terminal hotkey handling for record command
2
+ const readline = require('readline');
3
+ const chalk = require('chalk');
4
+
5
+ /**
6
+ * Set up terminal hotkey listener with raw mode
7
+ * @param {Object} sessionState - Recording session state
8
+ * @param {Function} onCapture - Callback when 'C' is pressed
9
+ * @returns {Function} Cleanup function to restore terminal
10
+ */
11
+ function setupTerminalHotkeys(sessionState, onCapture) {
12
+ if (!process.stdin.isTTY) {
13
+ console.warn(chalk.yellow('⚠ Terminal is not in TTY mode, hotkeys may not work'));
14
+ return () => {};
15
+ }
16
+
17
+ const rl = readline.createInterface({
18
+ input: process.stdin,
19
+ escapeCodeTimeout: 50
20
+ });
21
+
22
+ readline.emitKeypressEvents(process.stdin, rl);
23
+ process.stdin.setRawMode(true);
24
+
25
+ // Print hotkey instructions
26
+ console.log(chalk.cyan('šŸŽÆ Recording Controls:'));
27
+ console.log(chalk.gray(' Navigate to the state you want, then press C to capture (screenshot or clip).'));
28
+ console.log(chalk.gray(' Press Q - Quit and save the visual'));
29
+ console.log(chalk.gray(' Press Ctrl+C - Quit without saving\n'));
30
+
31
+ console.log(chalk.green('āœ” Setup complete. When you are ready, press C to start capturing.\n'));
32
+
33
+ const keypressHandler = (str, key) => {
34
+ if (!key) return;
35
+
36
+ // Ctrl+C - hard quit
37
+ if (key.ctrl && key.name === 'c') {
38
+ console.log(chalk.yellow('\n\n⚠ Interrupted. Exiting without saving...'));
39
+ sessionState.quit = true;
40
+ sessionState.saveOnQuit = false;
41
+ if (sessionState.onChange) {
42
+ sessionState.onChange();
43
+ }
44
+ return;
45
+ }
46
+
47
+ // Q - quit and save
48
+ if (key.name === 'q' && !key.ctrl) {
49
+ console.log(chalk.cyan('\n\nšŸ“ Quitting recording session...'));
50
+ sessionState.quit = true;
51
+ sessionState.saveOnQuit = true;
52
+ if (sessionState.onChange) {
53
+ sessionState.onChange();
54
+ }
55
+ return;
56
+ }
57
+
58
+ // C - capture (screenshot or clip)
59
+ if (key.name === 'c' && !key.ctrl) {
60
+ // Check if we're stopping a clip recording
61
+ if (sessionState.mode === 'recording-clip') {
62
+ console.log(chalk.cyan('\n\nšŸ›‘ Stopping clip recording...'));
63
+ sessionState.stopClipRecording = true;
64
+ if (sessionState.onChange) {
65
+ sessionState.onChange();
66
+ }
67
+ return;
68
+ }
69
+
70
+ // Otherwise, start capture flow
71
+ if (sessionState.mode === 'normal') {
72
+ if (sessionState.phase === 'idle') {
73
+ sessionState.phase = 'capturing';
74
+ }
75
+ console.log(chalk.cyan('\n\nšŸ“ø Starting capture flow...'));
76
+ if (onCapture) {
77
+ // Let the capture flow manage terminal settings as needed.
78
+ onCapture().catch((error) => {
79
+ console.error(chalk.red('Capture error:'), error.message);
80
+ });
81
+ }
82
+ }
83
+ }
84
+ };
85
+
86
+ process.stdin.on('keypress', keypressHandler);
87
+
88
+ // Return cleanup function
89
+ return () => {
90
+ process.stdin.removeListener('keypress', keypressHandler);
91
+ if (process.stdin.isTTY) {
92
+ process.stdin.setRawMode(false);
93
+ }
94
+ rl.close();
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Main event loop that waits for session to complete
100
+ * @param {Object} sessionState - Recording session state
101
+ */
102
+ async function runEventLoop(sessionState) {
103
+ return new Promise((resolve) => {
104
+ sessionState.onChange = () => {
105
+ if (sessionState.quit) {
106
+ resolve();
107
+ }
108
+ };
109
+
110
+ // Keep process alive
111
+ const interval = setInterval(() => {
112
+ if (sessionState.quit) {
113
+ clearInterval(interval);
114
+ }
115
+ }, 100);
116
+ });
117
+ }
118
+
119
+ module.exports = {
120
+ setupTerminalHotkeys,
121
+ runEventLoop
122
+ };
123
+