@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,587 @@
1
+ // ui-executor.js - Job execution and management for UI
2
+ const { spawn } = require("child_process");
3
+ const path = require("path");
4
+ const fs = require("fs-extra");
5
+ const { v4: uuidv4 } = require("uuid");
6
+
7
+ const JOBS_FILE = path.join(process.cwd(), ".reshot", "ui-jobs.json");
8
+ const MAX_JOBS_HISTORY = 100;
9
+
10
+ // Track running processes for cancellation
11
+ const runningProcesses = new Map(); // jobId -> { child, timeout }
12
+
13
+ /**
14
+ * Job model
15
+ * @typedef {Object} Job
16
+ * @property {string} id
17
+ * @property {string} type - 'run' | 'publish' | 'record' | 'crop-helper'
18
+ * @property {string} status - 'pending' | 'running' | 'success' | 'failed'
19
+ * @property {string} createdAt
20
+ * @property {string} updatedAt
21
+ * @property {string|null} scenarioKey
22
+ * @property {string[]} logs
23
+ * @property {Object} metadata
24
+ */
25
+
26
+ /**
27
+ * Load jobs from disk
28
+ */
29
+ function loadJobs() {
30
+ try {
31
+ if (fs.existsSync(JOBS_FILE)) {
32
+ return fs.readJSONSync(JOBS_FILE);
33
+ }
34
+ } catch (error) {
35
+ console.warn("Failed to load jobs file:", error.message);
36
+ }
37
+ return [];
38
+ }
39
+
40
+ /**
41
+ * Save jobs to disk
42
+ */
43
+ function saveJobs(jobs) {
44
+ try {
45
+ fs.ensureDirSync(path.dirname(JOBS_FILE));
46
+ // Keep only last MAX_JOBS_HISTORY jobs
47
+ const jobsToSave = jobs.slice(-MAX_JOBS_HISTORY);
48
+ fs.writeJSONSync(JOBS_FILE, jobsToSave, { spaces: 2 });
49
+ } catch (error) {
50
+ console.error("Failed to save jobs file:", error.message);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Create a new job
56
+ */
57
+ function createJob(type, metadata = {}) {
58
+ const job = {
59
+ id: uuidv4(),
60
+ type,
61
+ status: "pending",
62
+ createdAt: new Date().toISOString(),
63
+ updatedAt: new Date().toISOString(),
64
+ scenarioKey: metadata.scenarioKey || (metadata.scenarioKeys?.length === 1 ? metadata.scenarioKeys[0] : null),
65
+ logs: [],
66
+ metadata,
67
+ };
68
+
69
+ const jobs = loadJobs();
70
+ jobs.push(job);
71
+ saveJobs(jobs);
72
+
73
+ return job;
74
+ }
75
+
76
+ /**
77
+ * Update job status
78
+ */
79
+ function updateJobStatus(jobId, status, additionalData = {}) {
80
+ const jobs = loadJobs();
81
+ const jobIndex = jobs.findIndex((j) => j.id === jobId);
82
+
83
+ if (jobIndex === -1) {
84
+ throw new Error(`Job ${jobId} not found`);
85
+ }
86
+
87
+ jobs[jobIndex] = {
88
+ ...jobs[jobIndex],
89
+ ...additionalData,
90
+ status,
91
+ updatedAt: new Date().toISOString(),
92
+ };
93
+
94
+ saveJobs(jobs);
95
+ return jobs[jobIndex];
96
+ }
97
+
98
+ /**
99
+ * Append log to job
100
+ */
101
+ function appendJobLog(jobId, logLine) {
102
+ const jobs = loadJobs();
103
+ const jobIndex = jobs.findIndex((j) => j.id === jobId);
104
+
105
+ if (jobIndex === -1) {
106
+ return;
107
+ }
108
+
109
+ jobs[jobIndex].logs.push({
110
+ timestamp: new Date().toISOString(),
111
+ message: logLine,
112
+ });
113
+
114
+ // Keep only last 1000 log lines
115
+ if (jobs[jobIndex].logs.length > 1000) {
116
+ jobs[jobIndex].logs = jobs[jobIndex].logs.slice(-1000);
117
+ }
118
+
119
+ jobs[jobIndex].updatedAt = new Date().toISOString();
120
+ saveJobs(jobs);
121
+ }
122
+
123
+ /**
124
+ * Get job by ID
125
+ */
126
+ function getJob(jobId) {
127
+ const jobs = loadJobs();
128
+ return jobs.find((j) => j.id === jobId) || null;
129
+ }
130
+
131
+ /**
132
+ * Get all jobs
133
+ */
134
+ function getAllJobs(limit = 50) {
135
+ const jobs = loadJobs();
136
+ return jobs.slice(-limit).reverse(); // Most recent first
137
+ }
138
+
139
+ /**
140
+ * Clean up stuck jobs (jobs that have been running for more than 5 minutes without updates)
141
+ */
142
+ function cleanupStuckJobs() {
143
+ const jobs = loadJobs();
144
+ const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
145
+ let cleaned = false;
146
+
147
+ for (const job of jobs) {
148
+ if (job.status === "running") {
149
+ const updatedAtDate = new Date(job.updatedAt);
150
+ // Skip if date is invalid
151
+ if (isNaN(updatedAtDate.getTime())) continue;
152
+ const updatedAt = updatedAtDate.getTime();
153
+ if (updatedAt < fiveMinutesAgo) {
154
+ // Job has been running for more than 5 minutes without updates, mark as failed
155
+ const jobIndex = jobs.findIndex((j) => j.id === job.id);
156
+ if (jobIndex !== -1) {
157
+ jobs[jobIndex].status = "failed";
158
+ jobs[jobIndex].updatedAt = new Date().toISOString();
159
+ if (!jobs[jobIndex].metadata) {
160
+ jobs[jobIndex].metadata = {};
161
+ }
162
+ jobs[jobIndex].metadata.error =
163
+ "Job timed out (no activity for 5 minutes) - platform may be unavailable";
164
+ jobs[jobIndex].logs.push({
165
+ timestamp: new Date().toISOString(),
166
+ message:
167
+ "[error] Job was stuck in running state and has been marked as failed",
168
+ });
169
+ cleaned = true;
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ if (cleaned) {
176
+ saveJobs(jobs);
177
+ }
178
+
179
+ return cleaned;
180
+ }
181
+
182
+ /**
183
+ * Execute a CLI command as a job
184
+ */
185
+ function executeJob(jobId, command, args = [], options = {}) {
186
+ return new Promise((resolve, reject) => {
187
+ // Always use the local CLI script — global installs may have stale paths
188
+ // __dirname is in src/lib, so go up one level to get to src/index.js
189
+ let cliPath = path.resolve(__dirname, "..", "index.js");
190
+ let useNode = true;
191
+
192
+ // Ensure the file exists
193
+ if (!fs.existsSync(cliPath)) {
194
+ const error = `CLI script not found at ${cliPath}`;
195
+ updateJobStatus(jobId, "failed", { error });
196
+ const jobs = loadJobs();
197
+ const jobIndex = jobs.findIndex((j) => j.id === jobId);
198
+ if (jobIndex !== -1) {
199
+ jobs[jobIndex].logs.push({
200
+ timestamp: new Date().toISOString(),
201
+ message: `[error] ${error}`,
202
+ });
203
+ jobs[jobIndex].logs.push({
204
+ timestamp: new Date().toISOString(),
205
+ message: `[error] Current working directory: ${process.cwd()}`,
206
+ });
207
+ jobs[jobIndex].logs.push({
208
+ timestamp: new Date().toISOString(),
209
+ message: `[error] __dirname: ${__dirname}`,
210
+ });
211
+ saveJobs(jobs);
212
+ }
213
+ return reject(new Error(error));
214
+ }
215
+
216
+ const isInteractive = command === "record"; // Record is interactive
217
+
218
+ // Build command and args
219
+ const cmd = useNode ? "node" : cliPath;
220
+ const cmdArgs = useNode ? [cliPath, command, ...args] : [command, ...args];
221
+
222
+ // Update status and add initial logs
223
+ updateJobStatus(jobId, "running");
224
+ appendJobLog(
225
+ jobId,
226
+ `[info] Executing: ${useNode ? "node" : "reshot"} ${command} ${args.join(
227
+ " "
228
+ )}`
229
+ );
230
+ appendJobLog(jobId, `[info] Working directory: ${process.cwd()}`);
231
+ appendJobLog(
232
+ jobId,
233
+ `[info] Using ${useNode ? "local" : "global"} CLI: ${
234
+ useNode ? cliPath : "reshot"
235
+ }`
236
+ );
237
+
238
+ // Use node to run the CLI script
239
+ const child = spawn(cmd, cmdArgs, {
240
+ cwd: process.cwd(),
241
+ env: {
242
+ ...process.env,
243
+ ...options.env,
244
+ // Preserve color output for better logs
245
+ FORCE_COLOR: "1",
246
+ NODE_ENV: process.env.NODE_ENV || "production",
247
+ },
248
+ stdio: isInteractive
249
+ ? ["pipe", "pipe", "pipe"]
250
+ : ["ignore", "pipe", "pipe"],
251
+ shell: false,
252
+ });
253
+
254
+ let stdoutBuffer = "";
255
+ let stderrBuffer = "";
256
+ let hasOutput = false;
257
+
258
+ // Set a timeout for non-interactive commands (10 minutes max)
259
+ const timeout = isInteractive
260
+ ? null
261
+ : setTimeout(() => {
262
+ if (child && !child.killed) {
263
+ appendJobLog(jobId, "[error] Job timed out after 10 minutes");
264
+ child.kill("SIGTERM");
265
+ runningProcesses.delete(jobId);
266
+ updateJobStatus(jobId, "failed", {
267
+ error: "Job timed out after 10 minutes",
268
+ });
269
+ resolve({
270
+ code: -1,
271
+ stdout: stdoutBuffer,
272
+ stderr: stderrBuffer,
273
+ error: "Timeout",
274
+ });
275
+ }
276
+ }, 10 * 60 * 1000);
277
+
278
+ // Track the process for cancellation
279
+ runningProcesses.set(jobId, { child, timeout });
280
+
281
+ child.stdout.on("data", (data) => {
282
+ hasOutput = true;
283
+ const text = data.toString();
284
+ stdoutBuffer += text;
285
+ // Split by newlines and log each line
286
+ const lines = text
287
+ .split(/\r?\n/)
288
+ .filter((line) => line.trim().length > 0);
289
+ lines.forEach((line) => {
290
+ appendJobLog(jobId, line);
291
+ });
292
+ });
293
+
294
+ child.stderr.on("data", (data) => {
295
+ hasOutput = true;
296
+ const text = data.toString();
297
+ stderrBuffer += text;
298
+ // Split by newlines and log each line
299
+ const lines = text
300
+ .split(/\r?\n/)
301
+ .filter((line) => line.trim().length > 0);
302
+ lines.forEach((line) => {
303
+ appendJobLog(jobId, `[stderr] ${line}`);
304
+ });
305
+ });
306
+
307
+ // For interactive commands like record, we can't fully automate
308
+ // But we'll let it start and log what happens
309
+ if (isInteractive) {
310
+ appendJobLog(
311
+ jobId,
312
+ "[info] Interactive command - may require manual input"
313
+ );
314
+ appendJobLog(
315
+ jobId,
316
+ "[info] Ensure Chrome is running with remote debugging enabled"
317
+ );
318
+ }
319
+
320
+ child.on("close", (code, signal) => {
321
+ if (timeout) clearTimeout(timeout);
322
+ runningProcesses.delete(jobId);
323
+
324
+ if (signal) {
325
+ appendJobLog(jobId, `[info] Process terminated by signal: ${signal}`);
326
+ updateJobStatus(jobId, "failed", {
327
+ exitCode: -1,
328
+ error: `Process terminated by signal: ${signal}`,
329
+ });
330
+ return resolve({
331
+ code: -1,
332
+ stdout: stdoutBuffer,
333
+ stderr: stderrBuffer,
334
+ error: `Terminated: ${signal}`,
335
+ });
336
+ }
337
+
338
+ appendJobLog(jobId, `[info] Process exited with code ${code}`);
339
+
340
+ // If no output was received and process exited quickly, it might have failed to start
341
+ if (!hasOutput && code !== 0) {
342
+ appendJobLog(
343
+ jobId,
344
+ "[error] Process exited with no output - command may have failed to start"
345
+ );
346
+ const errorMsg =
347
+ stderrBuffer || `Process exited with code ${code} and no output`;
348
+ updateJobStatus(jobId, "failed", {
349
+ exitCode: code,
350
+ error: errorMsg,
351
+ });
352
+ return resolve({
353
+ code,
354
+ stdout: stdoutBuffer,
355
+ stderr: stderrBuffer,
356
+ error: errorMsg,
357
+ });
358
+ }
359
+
360
+ // Check if job was cancelled
361
+ const job = getJob(jobId);
362
+ if (job && job.status === "cancelled") {
363
+ // Already marked as cancelled, don't override
364
+ resolve({
365
+ code,
366
+ stdout: stdoutBuffer,
367
+ stderr: stderrBuffer,
368
+ cancelled: true,
369
+ });
370
+ return;
371
+ }
372
+
373
+ // Check for common error patterns in output even if exit code is 0
374
+ // Be more specific to avoid false positives - look for actual error patterns, not just keywords
375
+ const authErrorPatterns = [
376
+ /(?:invalid|missing|expired|bad|unauthorized).*api.?key/i,
377
+ /api.?key.*(?:invalid|missing|expired|bad|required)/i,
378
+ /401.*unauthorized/i,
379
+ /authentication.*(?:failed|error|required)/i,
380
+ /(?:failed|error).*authentication/i,
381
+ ];
382
+ const hasAuthError = authErrorPatterns.some(
383
+ (pattern) =>
384
+ pattern.test(stderrBuffer) ||
385
+ (code !== 0 && pattern.test(stdoutBuffer))
386
+ );
387
+
388
+ const hasConnectionError =
389
+ stderrBuffer.includes("ECONNREFUSED") ||
390
+ stderrBuffer.includes("ENOTFOUND") ||
391
+ stderrBuffer.includes("connect ETIMEDOUT") ||
392
+ (code !== 0 &&
393
+ (stdoutBuffer.includes("ECONNREFUSED") ||
394
+ stdoutBuffer.includes("ENOTFOUND")));
395
+
396
+ if (hasAuthError) {
397
+ const errorMsg =
398
+ "Authentication failed - check API key and run 'reshot auth'";
399
+ appendJobLog(jobId, `[error] ${errorMsg}`);
400
+ updateJobStatus(jobId, "failed", {
401
+ exitCode: code,
402
+ error: errorMsg,
403
+ });
404
+ return resolve({
405
+ code,
406
+ stdout: stdoutBuffer,
407
+ stderr: stderrBuffer,
408
+ error: errorMsg,
409
+ });
410
+ }
411
+
412
+ if (hasConnectionError) {
413
+ const errorMsg =
414
+ "Cannot connect to platform - ensure the server is running";
415
+ appendJobLog(jobId, `[error] ${errorMsg}`);
416
+ updateJobStatus(jobId, "failed", {
417
+ exitCode: code,
418
+ error: errorMsg,
419
+ });
420
+ return resolve({
421
+ code,
422
+ stdout: stdoutBuffer,
423
+ stderr: stderrBuffer,
424
+ error: errorMsg,
425
+ });
426
+ }
427
+
428
+ if (code === 0) {
429
+ updateJobStatus(jobId, "success", {
430
+ exitCode: code,
431
+ });
432
+ resolve({ code, stdout: stdoutBuffer, stderr: stderrBuffer });
433
+ } else {
434
+ const errorMsg = stderrBuffer || `Process exited with code ${code}`;
435
+ updateJobStatus(jobId, "failed", {
436
+ exitCode: code,
437
+ error: errorMsg,
438
+ });
439
+ // Don't reject for non-zero exit codes - some commands may exit with codes for expected reasons
440
+ // Just mark as failed and resolve
441
+ resolve({
442
+ code,
443
+ stdout: stdoutBuffer,
444
+ stderr: stderrBuffer,
445
+ error: errorMsg,
446
+ });
447
+ }
448
+ });
449
+
450
+ child.on("error", (error) => {
451
+ if (timeout) clearTimeout(timeout);
452
+ runningProcesses.delete(jobId);
453
+ const errorMsg = error.message;
454
+ updateJobStatus(jobId, "failed", {
455
+ error: errorMsg,
456
+ });
457
+ appendJobLog(jobId, `[error] Failed to start process: ${errorMsg}`);
458
+ appendJobLog(jobId, `[error] Command: ${cmd} ${cmdArgs.join(" ")}`);
459
+ reject(error);
460
+ });
461
+ });
462
+ }
463
+
464
+ /**
465
+ * Cancel a running job
466
+ */
467
+ function cancelJob(jobId) {
468
+ const processInfo = runningProcesses.get(jobId);
469
+
470
+ if (processInfo) {
471
+ const { child, timeout } = processInfo;
472
+
473
+ // Clear the timeout
474
+ if (timeout) clearTimeout(timeout);
475
+
476
+ // Kill the process
477
+ if (child && !child.killed) {
478
+ child.kill("SIGTERM");
479
+ // Force kill after 5 seconds if still running
480
+ setTimeout(() => {
481
+ if (!child.killed) {
482
+ child.kill("SIGKILL");
483
+ }
484
+ }, 5000);
485
+ }
486
+
487
+ runningProcesses.delete(jobId);
488
+ appendJobLog(jobId, "[info] Job cancelled by user");
489
+ updateJobStatus(jobId, "cancelled");
490
+ return true;
491
+ }
492
+
493
+ // Job not running, just update status if it's in running state
494
+ const job = getJob(jobId);
495
+ if (job && job.status === "running") {
496
+ appendJobLog(jobId, "[info] Job marked as cancelled (process not found)");
497
+ updateJobStatus(jobId, "cancelled");
498
+ return true;
499
+ }
500
+
501
+ return false;
502
+ }
503
+
504
+ /**
505
+ * Execute run job
506
+ * @param {string} jobId - Job ID
507
+ * @param {string[]|null} scenarioKeys - Optional scenario keys to run
508
+ * @param {Object|null} variant - Optional variant configuration (e.g., { locale: 'ko', role: 'admin' })
509
+ * @param {string|null} format - Optional output format ('step-by-step-images' or 'summary-video')
510
+ * @param {boolean|null} diff - Whether to enable baseline diffing (null = use config default)
511
+ */
512
+ async function executeRunJob(
513
+ jobId,
514
+ scenarioKeys = null,
515
+ variant = null,
516
+ format = null,
517
+ diff = null,
518
+ noPrivacy = false,
519
+ noStyle = false
520
+ ) {
521
+ const args = [];
522
+ // Pass scenario keys if provided (comma-separated)
523
+ if (scenarioKeys && Array.isArray(scenarioKeys) && scenarioKeys.length > 0) {
524
+ args.push("--scenarios", scenarioKeys.join(","));
525
+ }
526
+ // Pass variant as JSON string if provided
527
+ if (
528
+ variant &&
529
+ typeof variant === "object" &&
530
+ Object.keys(variant).length > 0
531
+ ) {
532
+ args.push("--variant", JSON.stringify(variant));
533
+ }
534
+ // Pass format if provided
535
+ if (format) {
536
+ args.push("--format", format);
537
+ }
538
+ // Pass diff flag if explicitly set
539
+ if (diff === true) {
540
+ args.push("--diff");
541
+ } else if (diff === false) {
542
+ args.push("--no-diff");
543
+ }
544
+ // Pass privacy/style flags
545
+ if (noPrivacy) {
546
+ args.push("--no-privacy");
547
+ }
548
+ if (noStyle) {
549
+ args.push("--no-style");
550
+ }
551
+ return executeJob(jobId, "run", args);
552
+ }
553
+
554
+ /**
555
+ * Execute publish job
556
+ */
557
+ async function executePublishJob(jobId, scenarioKeys = null) {
558
+ const args = [];
559
+ // For now, publish all. Future: filter by scenarioKeys
560
+ return executeJob(jobId, "publish", args);
561
+ }
562
+
563
+ /**
564
+ * Execute record job
565
+ */
566
+ async function executeRecordJob(jobId, title, scenarioKey = null) {
567
+ const args = title ? [title] : [];
568
+ // If scenarioKey is provided, we'd need to pass it somehow
569
+ // For now, just use the title
570
+ return executeJob(jobId, "record", args);
571
+ }
572
+
573
+ module.exports = {
574
+ createJob,
575
+ updateJobStatus,
576
+ appendJobLog,
577
+ getJob,
578
+ getAllJobs,
579
+ executeJob,
580
+ executeRunJob,
581
+ executePublishJob,
582
+ executeRecordJob,
583
+ cancelJob,
584
+ cleanupStuckJobs,
585
+ loadJobs,
586
+ saveJobs,
587
+ };