@reshotdev/screenshot 0.0.1-beta.12 → 0.0.1-beta.13

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 (60) hide show
  1. package/README.md +67 -22
  2. package/package.json +18 -14
  3. package/src/commands/auth.js +37 -7
  4. package/src/commands/capture-dom.js +50 -0
  5. package/src/commands/compose.js +220 -0
  6. package/src/commands/doctor-target.js +36 -4
  7. package/src/commands/drifts.js +13 -1
  8. package/src/commands/publish.js +137 -12
  9. package/src/commands/pull.js +9 -4
  10. package/src/commands/refresh.js +166 -0
  11. package/src/commands/setup-wizard.js +35 -2
  12. package/src/commands/status.js +22 -2
  13. package/src/commands/variation.js +194 -0
  14. package/src/index.js +187 -9
  15. package/src/lib/api-client.js +61 -35
  16. package/src/lib/auto-update/refresh.js +598 -0
  17. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  18. package/src/lib/auto-update/spec.js +89 -0
  19. package/src/lib/capture-engine.js +73 -0
  20. package/src/lib/capture-script-runner.js +280 -134
  21. package/src/lib/certification.js +23 -1
  22. package/src/lib/compose-context.js +156 -0
  23. package/src/lib/compose-pack.js +42 -0
  24. package/src/lib/compose-runtime.js +34 -0
  25. package/src/lib/compose-upload.js +142 -0
  26. package/src/lib/config.js +2 -2
  27. package/src/lib/dom-capture.js +64 -0
  28. package/src/lib/record-clip.js +83 -3
  29. package/src/lib/record-config.js +0 -4
  30. package/src/lib/resolve-targets.js +60 -0
  31. package/src/lib/run-manifest.js +45 -0
  32. package/src/lib/ui-api-helpers.js +118 -0
  33. package/src/lib/ui-api.js +28 -820
  34. package/src/lib/ui-asset-cleanup.js +62 -0
  35. package/src/lib/ui-output-versions.js +165 -0
  36. package/src/lib/ui-recorder-routes.js +341 -0
  37. package/src/lib/ui-scenario-metadata.js +161 -0
  38. package/vendor/compose/dist/auto-update.cjs +5544 -0
  39. package/vendor/compose/dist/auto-update.mjs +5518 -0
  40. package/vendor/compose/dist/capture.cjs +1450 -0
  41. package/vendor/compose/dist/capture.mjs +1416 -0
  42. package/vendor/compose/dist/eligibility.cjs +5331 -0
  43. package/vendor/compose/dist/eligibility.mjs +5313 -0
  44. package/vendor/compose/dist/index.cjs +2046 -0
  45. package/vendor/compose/dist/index.mjs +1997 -0
  46. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  47. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  48. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  49. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  50. package/vendor/compose/dist/render.cjs +558 -0
  51. package/vendor/compose/dist/render.mjs +515 -0
  52. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  53. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  54. package/vendor/compose/dist/verify.cjs +3880 -0
  55. package/vendor/compose/dist/verify.mjs +3858 -0
  56. package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
  57. package/web/manager/dist/index.html +1 -1
  58. package/src/commands/ingest.js +0 -458
  59. package/src/commands/setup.js +0 -165
  60. package/src/lib/playwright-runner.js +0 -252
package/src/lib/ui-api.js CHANGED
@@ -36,117 +36,20 @@ const {
36
36
  applyStyle,
37
37
  isStyleAvailable,
38
38
  } = require("./style-engine");
39
-
40
- /**
41
- * Get the platform URL from settings, falling back to localhost for development
42
- * @param {Object} settings - CLI settings object
43
- * @returns {string} Platform URL
44
- */
45
- function getPlatformUrl(settings) {
46
- // Priority: settings.platformUrl > env var > production default
47
- if (settings?.platformUrl) {
48
- return settings.platformUrl;
49
- }
50
- const envUrl = process.env.RESHOT_API_BASE_URL;
51
- if (envUrl) {
52
- // Remove /api suffix if present to get platform URL
53
- return envUrl.replace(/\/api\/?$/, "");
54
- }
55
- return "https://reshot.dev";
56
- }
57
-
58
- /**
59
- * Handle API errors and detect if re-auth is needed
60
- * @param {Error} error - The error from API call
61
- * @param {Object} res - Express response object
62
- * @returns {Object|null} Response if error was handled, null otherwise
63
- */
64
- function handleApiError(error, res) {
65
- if (config.isAuthError(error)) {
66
- const errorMsg =
67
- error.response?.data?.error ||
68
- error.message ||
69
- "API key is invalid or expired";
70
- return res.status(401).json(config.createAuthErrorResponse(errorMsg));
71
- }
72
- return null; // Error not handled, let caller handle it
73
- }
74
-
75
- /**
76
- * Generate all possible variant combinations from dimensions
77
- * @param {Object} dimensions - Variant dimensions config
78
- * @param {string[]} dimensionKeys - Which dimensions to include
79
- * @returns {Array<Object>} Array of variant objects
80
- */
81
- function generateVariantCombinations(dimensions, dimensionKeys = []) {
82
- if (!dimensions || dimensionKeys.length === 0) {
83
- return [];
84
- }
85
-
86
- // Get options for each dimension
87
- const dimensionOptions = dimensionKeys
88
- .map((key) => {
89
- const dim = dimensions[key];
90
- if (!dim?.options) return [];
91
- return Object.keys(dim.options).map((optKey) => ({
92
- dimension: key,
93
- option: optKey,
94
- }));
95
- })
96
- .filter((opts) => opts.length > 0);
97
-
98
- if (dimensionOptions.length === 0) {
99
- return [];
100
- }
101
-
102
- // Generate cartesian product of all dimension options
103
- const cartesian = (...arrays) => {
104
- return arrays.reduce(
105
- (acc, arr) => acc.flatMap((combo) => arr.map((item) => [...combo, item])),
106
- [[]],
107
- );
108
- };
109
-
110
- const combinations = cartesian(...dimensionOptions);
111
-
112
- // Convert to variant objects
113
- return combinations.map((combo) => {
114
- const variant = {};
115
- for (const { dimension, option } of combo) {
116
- variant[dimension] = option;
117
- }
118
- return variant;
119
- });
120
- }
121
-
122
- /**
123
- * Validate a path segment to prevent directory traversal attacks
124
- * @param {string} segment - Path segment to validate
125
- * @returns {boolean} True if safe, false if potentially malicious
126
- */
127
- function isValidPathSegment(segment) {
128
- if (!segment || typeof segment !== "string") return false;
129
- // Reject empty, dots-only, or segments with path separators
130
- if (segment === "." || segment === "..") return false;
131
- if (segment.includes("/") || segment.includes("\\")) return false;
132
- if (segment.includes("\0")) return false; // Null byte injection
133
- return true;
134
- }
135
-
136
- /**
137
- * Validate that a resolved path stays within the expected base directory
138
- * @param {string} resolvedPath - Fully resolved path
139
- * @param {string} baseDir - Expected base directory
140
- * @returns {boolean} True if path is within base, false otherwise
141
- */
142
- function isPathWithinBase(resolvedPath, baseDir) {
143
- const normalizedBase = path.resolve(baseDir);
144
- const normalizedPath = path.resolve(resolvedPath);
145
- return (
146
- normalizedPath.startsWith(normalizedBase + path.sep) ||
147
- normalizedPath === normalizedBase
148
- );
149
- }
39
+ const {
40
+ generateVariantCombinations,
41
+ getPlatformUrl,
42
+ handleApiError,
43
+ isPathWithinBase,
44
+ isValidPathSegment,
45
+ } = require("./ui-api-helpers");
46
+ const {
47
+ deleteAllOutputAssets,
48
+ deleteScenarioAssetDirectories,
49
+ } = require("./ui-asset-cleanup");
50
+ const { listScenarioVersions } = require("./ui-output-versions");
51
+ const { addScenarioMetadata } = require("./ui-scenario-metadata");
52
+ const { attachRecorderRoutes } = require("./ui-recorder-routes");
150
53
 
151
54
  /**
152
55
  * Attach all API routes to an Express app
@@ -218,134 +121,13 @@ function attachApiRoutes(app, context) {
218
121
  : { scenarios: [] };
219
122
  const scenarios = docSyncConfig?.scenarios || [];
220
123
  const outputBaseDir = path.join(process.cwd(), ".reshot", "output");
221
-
222
- // Get all jobs to find last run times
124
+ const uiExecutor = require("./ui-executor");
223
125
  const allJobs = uiExecutor.getAllJobs(500);
224
-
225
- // Build metadata for each scenario
226
- const scenariosWithMetadata = scenarios.map((scenario) => {
227
- let createdAt = null;
228
- let lastRunAt = null;
229
- let lastRunStatus = null;
230
- let assetCount = 0;
231
-
232
- // Try to find creation date from earliest output folder
233
- const scenarioOutputDir = path.join(outputBaseDir, scenario.key);
234
- if (fs.existsSync(scenarioOutputDir)) {
235
- try {
236
- const subFolders = fs
237
- .readdirSync(scenarioOutputDir)
238
- .filter((item) => {
239
- const fullPath = path.join(scenarioOutputDir, item);
240
- try {
241
- return (
242
- fs.statSync(fullPath).isDirectory() && item !== "latest"
243
- );
244
- } catch {
245
- return false;
246
- }
247
- });
248
-
249
- // Parse timestamps from folder names (format: YYYY-MM-DD_HH-MM-SS)
250
- const timestamps = subFolders
251
- .filter((f) => /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(f))
252
- .map((f) => {
253
- const [date, time] = f.split("_");
254
- const [year, month, day] = date.split("-");
255
- const [hour, min, sec] = time.split("-");
256
- return new Date(
257
- `${year}-${month}-${day}T${hour}:${min}:${sec}`,
258
- );
259
- })
260
- .filter((d) => !isNaN(d.getTime()))
261
- .sort((a, b) => a.getTime() - b.getTime());
262
-
263
- if (timestamps.length > 0) {
264
- createdAt = timestamps[0].toISOString();
265
- lastRunAt = timestamps[timestamps.length - 1].toISOString();
266
- }
267
-
268
- // Count assets in latest folder
269
- const latestDir = path.join(scenarioOutputDir, "latest");
270
- if (fs.existsSync(latestDir)) {
271
- try {
272
- const files = fs.readdirSync(latestDir);
273
- assetCount = files.filter(
274
- (f) =>
275
- f.endsWith(".png") ||
276
- f.endsWith(".jpg") ||
277
- f.endsWith(".mp4") ||
278
- f.endsWith(".webm"),
279
- ).length;
280
- } catch {}
281
- }
282
- } catch (err) {
283
- // Ignore errors reading output directories
284
- }
285
- }
286
-
287
- // Also check jobs for more accurate last run info
288
- const scenarioJobs = allJobs.filter((job) => {
289
- if (job.type !== "run") return false;
290
- const keys = job.params?.scenarioKeys || [];
291
- return keys.includes(scenario.key);
292
- });
293
-
294
- if (scenarioJobs.length > 0) {
295
- // Sort by createdAt desc
296
- scenarioJobs.sort(
297
- (a, b) =>
298
- new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
299
- );
300
- const latestJob = scenarioJobs[0];
301
-
302
- // Use job completion time if available
303
- if (latestJob.completedAt) {
304
- const jobTime = new Date(latestJob.completedAt).toISOString();
305
- if (!lastRunAt || jobTime > lastRunAt) {
306
- lastRunAt = jobTime;
307
- lastRunStatus = latestJob.status;
308
- }
309
- } else if (latestJob.createdAt) {
310
- const jobTime = new Date(latestJob.createdAt).toISOString();
311
- if (!lastRunAt || jobTime > lastRunAt) {
312
- lastRunAt = jobTime;
313
- lastRunStatus = latestJob.status;
314
- }
315
- }
316
-
317
- // Get earliest job for createdAt
318
- const earliestJob = scenarioJobs[scenarioJobs.length - 1];
319
- if (
320
- earliestJob.createdAt &&
321
- (!createdAt || earliestJob.createdAt < createdAt)
322
- ) {
323
- createdAt = earliestJob.createdAt;
324
- }
325
- }
326
-
327
- return {
328
- ...scenario,
329
- _metadata: {
330
- createdAt,
331
- lastRunAt,
332
- lastRunStatus,
333
- assetCount,
334
- },
335
- };
336
- });
337
-
338
- // Sort by lastRunAt descending (most recent first), fallback to name
339
- scenariosWithMetadata.sort((a, b) => {
340
- const aTime = a._metadata?.lastRunAt
341
- ? new Date(a._metadata.lastRunAt).getTime()
342
- : 0;
343
- const bTime = b._metadata?.lastRunAt
344
- ? new Date(b._metadata.lastRunAt).getTime()
345
- : 0;
346
- if (aTime !== bTime) return bTime - aTime;
347
- return a.name.localeCompare(b.name);
348
- });
126
+ const scenariosWithMetadata = addScenarioMetadata(
127
+ scenarios,
128
+ allJobs,
129
+ outputBaseDir,
130
+ );
349
131
 
350
132
  res.json({ scenarios: scenariosWithMetadata });
351
133
  } catch (error) {
@@ -726,29 +508,7 @@ function attachApiRoutes(app, context) {
726
508
  app.delete("/api/assets", async (req, res, next) => {
727
509
  try {
728
510
  const outputDir = path.join(process.cwd(), ".reshot", "output");
729
-
730
- if (!fs.existsSync(outputDir)) {
731
- return res.json({ ok: true, deleted: 0 });
732
- }
733
-
734
- // Count files before deletion
735
- let fileCount = 0;
736
- function countFiles(dir) {
737
- const items = fs.readdirSync(dir);
738
- for (const item of items) {
739
- const fullPath = path.join(dir, item);
740
- const stat = fs.statSync(fullPath);
741
- if (stat.isDirectory()) {
742
- countFiles(fullPath);
743
- } else {
744
- fileCount++;
745
- }
746
- }
747
- }
748
- countFiles(outputDir);
749
-
750
- // Remove all contents of output directory
751
- fs.emptyDirSync(outputDir);
511
+ const fileCount = deleteAllOutputAssets(outputDir);
752
512
 
753
513
  res.json({ ok: true, deleted: fileCount });
754
514
  } catch (error) {
@@ -784,46 +544,11 @@ function attachApiRoutes(app, context) {
784
544
  }
785
545
 
786
546
  const outputDir = path.join(process.cwd(), ".reshot", "output");
787
-
788
- if (!fs.existsSync(outputDir)) {
789
- return res.json({ ok: true, deletedScenarios: 0, deletedFiles: 0 });
790
- }
791
-
792
- let deletedScenarios = 0;
793
- let deletedFiles = 0;
794
-
795
- for (const scenarioKey of scenarioKeys) {
796
- const scenarioDir = path.join(outputDir, scenarioKey);
797
-
798
- // Verify the path is within the output directory
799
- if (!isPathWithinBase(scenarioDir, outputDir)) {
800
- continue; // Skip paths that escape the output directory
801
- }
802
-
803
- if (fs.existsSync(scenarioDir)) {
804
- // Count files in this scenario directory
805
- function countFilesInDir(dir) {
806
- let count = 0;
807
- const items = fs.readdirSync(dir);
808
- for (const item of items) {
809
- const fullPath = path.join(dir, item);
810
- const stat = fs.statSync(fullPath);
811
- if (stat.isDirectory()) {
812
- count += countFilesInDir(fullPath);
813
- } else {
814
- count++;
815
- }
816
- }
817
- return count;
818
- }
819
-
820
- deletedFiles += countFilesInDir(scenarioDir);
821
-
822
- // Remove the scenario directory
823
- fs.removeSync(scenarioDir);
824
- deletedScenarios++;
825
- }
826
- }
547
+ const { deletedScenarios, deletedFiles } = deleteScenarioAssetDirectories(
548
+ outputDir,
549
+ scenarioKeys,
550
+ isPathWithinBase,
551
+ );
827
552
 
828
553
  res.json({ ok: true, deletedScenarios, deletedFiles });
829
554
  } catch (error) {
@@ -1942,156 +1667,7 @@ function attachApiRoutes(app, context) {
1942
1667
  return res.json({ versions: [] });
1943
1668
  }
1944
1669
 
1945
- const isTimestamp = (name) =>
1946
- /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/.test(name);
1947
- const extensions = [".png", ".gif", ".mp4", ".jpg", ".jpeg", ".webm"];
1948
-
1949
- // Helper to count assets in a directory
1950
- function countAssets(dir) {
1951
- let count = 0;
1952
- function walk(d) {
1953
- try {
1954
- const items = fs.readdirSync(d);
1955
- for (const item of items) {
1956
- const fullPath = path.join(d, item);
1957
- const stat = fs.statSync(fullPath);
1958
- if (stat.isDirectory()) {
1959
- walk(fullPath);
1960
- } else if (
1961
- extensions.includes(path.extname(item).toLowerCase())
1962
- ) {
1963
- count++;
1964
- }
1965
- }
1966
- } catch (e) {
1967
- /* ignore */
1968
- }
1969
- }
1970
- walk(dir);
1971
- return count;
1972
- }
1973
-
1974
- // Helper to detect variant subfolders in a directory
1975
- function detectVariants(dir) {
1976
- try {
1977
- const items = fs.readdirSync(dir);
1978
- const variants = [];
1979
- for (const item of items) {
1980
- const fullPath = path.join(dir, item);
1981
- const stat = fs.statSync(fullPath);
1982
- if (stat.isDirectory()) {
1983
- // Check if this folder contains assets (is a variant folder)
1984
- const assetCount = countAssets(fullPath);
1985
- if (assetCount > 0) {
1986
- variants.push({
1987
- name: item,
1988
- assetCount,
1989
- path: fullPath,
1990
- });
1991
- }
1992
- }
1993
- }
1994
- return variants;
1995
- } catch (e) {
1996
- return [];
1997
- }
1998
- }
1999
-
2000
- const subFolders = fs.readdirSync(scenarioDir).filter((item) => {
2001
- const fullPath = path.join(scenarioDir, item);
2002
- try {
2003
- return fs.statSync(fullPath).isDirectory();
2004
- } catch {
2005
- return false;
2006
- }
2007
- });
2008
-
2009
- // Get timestamp folders sorted newest first
2010
- const timestampFolders = subFolders
2011
- .filter((f) => isTimestamp(f))
2012
- .sort()
2013
- .reverse();
2014
-
2015
- // Also include 'latest' and 'default' folders if they exist and have assets
2016
- const specialFolders = subFolders.filter(
2017
- (f) => f === "latest" || f === "default",
2018
- );
2019
-
2020
- const versions = [];
2021
-
2022
- // Add timestamp versions first (newest first)
2023
- timestampFolders.forEach((ts, index) => {
2024
- const tsPath = path.join(scenarioDir, ts);
2025
- const totalAssetCount = countAssets(tsPath);
2026
-
2027
- // Detect variants in this timestamp folder
2028
- const variants = detectVariants(tsPath);
2029
-
2030
- // Parse timestamp to human-readable format
2031
- const parts = ts.match(
2032
- /(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})/,
2033
- );
2034
- let label = ts;
2035
- let isoDate = ts;
2036
- if (parts) {
2037
- const date = new Date(
2038
- `${parts[1]}-${parts[2]}-${parts[3]}T${parts[4]}:${parts[5]}:${parts[6]}`,
2039
- );
2040
- // Validate the date is valid before using
2041
- if (!isNaN(date.getTime())) {
2042
- label = date.toLocaleString();
2043
- isoDate = date.toISOString();
2044
- }
2045
- }
2046
-
2047
- // Read manifest for privacy/style metadata
2048
- let manifestMeta = {};
2049
- try {
2050
- const manifestPath = path.join(tsPath, "manifest.json");
2051
- if (fs.existsSync(manifestPath)) {
2052
- const manifest = fs.readJSONSync(manifestPath);
2053
- if (manifest.privacy) manifestMeta.privacy = manifest.privacy;
2054
- if (manifest.style) manifestMeta.style = manifest.style;
2055
- }
2056
- } catch (_e) { /* ignore */ }
2057
-
2058
- versions.push({
2059
- timestamp: ts,
2060
- label,
2061
- date: isoDate,
2062
- assetCount: totalAssetCount,
2063
- isLatest: index === 0,
2064
- variants: variants.map((v) => ({
2065
- name: v.name,
2066
- assetCount: v.assetCount,
2067
- })),
2068
- hasVariants: variants.length > 0,
2069
- ...manifestMeta,
2070
- });
2071
- });
2072
-
2073
- // Add special folders at the end (but only if they have assets and not duplicating timestamp versions)
2074
- specialFolders.forEach((folder) => {
2075
- const folderPath = path.join(scenarioDir, folder);
2076
- const assetCount = countAssets(folderPath);
2077
- const variants = detectVariants(folderPath);
2078
- if (assetCount > 0) {
2079
- // Only add if we don't already have timestamp versions (to avoid redundancy)
2080
- const label = folder === "latest" ? "Latest" : "Default";
2081
- versions.push({
2082
- timestamp: folder,
2083
- label,
2084
- date: new Date().toISOString(),
2085
- assetCount,
2086
- isLatest: folder === "latest" && versions.length === 0,
2087
- variants: variants.map((v) => ({
2088
- name: v.name,
2089
- assetCount: v.assetCount,
2090
- })),
2091
- hasVariants: variants.length > 0,
2092
- });
2093
- }
2094
- });
1670
+ const versions = listScenarioVersions(scenarioDir);
2095
1671
 
2096
1672
  res.json({ versions });
2097
1673
  } catch (error) {
@@ -4297,375 +3873,7 @@ function attachApiRoutes(app, context) {
4297
3873
  }
4298
3874
  });
4299
3875
 
4300
- // ===== RECORDER ENDPOINTS =====
4301
- // These endpoints require the recorderService to be passed in context
4302
-
4303
- const { checkCdpEndpoint, getCdpTargets } = require("./record-cdp");
4304
-
4305
- /**
4306
- * GET /api/recorder/check-chrome
4307
- * Check if Chrome is running with remote debugging and get available tabs
4308
- */
4309
- app.get("/api/recorder/check-chrome", async (req, res, next) => {
4310
- try {
4311
- const endpointCheck = await checkCdpEndpoint("localhost", 9222);
4312
-
4313
- if (!endpointCheck.available) {
4314
- return res.json({
4315
- ok: false,
4316
- chromeAvailable: false,
4317
- error: endpointCheck.error,
4318
- instructions: {
4319
- darwin:
4320
- '/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.reshot/chrome-debug"',
4321
- win32:
4322
- '"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.reshot\\chrome-debug"',
4323
- linux:
4324
- 'google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.reshot/chrome-debug"',
4325
- },
4326
- });
4327
- }
4328
-
4329
- // Get list of available tabs
4330
- let targets = [];
4331
- try {
4332
- targets = await getCdpTargets("localhost", 9222);
4333
- } catch (e) {
4334
- // Ignore, we'll just have empty targets
4335
- }
4336
-
4337
- const pageTargets = targets.filter((t) => t.type === "page");
4338
- const validTargets = pageTargets.filter(
4339
- (t) =>
4340
- !t.url.startsWith("chrome://") &&
4341
- !t.url.startsWith("chrome-error://") &&
4342
- t.url !== "about:blank",
4343
- );
4344
-
4345
- res.json({
4346
- ok: true,
4347
- chromeAvailable: true,
4348
- browserInfo: endpointCheck.info,
4349
- tabs: pageTargets.map((t) => ({
4350
- title: t.title,
4351
- url: t.url,
4352
- isValid:
4353
- !t.url.startsWith("chrome://") &&
4354
- !t.url.startsWith("chrome-error://") &&
4355
- t.url !== "about:blank",
4356
- })),
4357
- hasValidTab: validTargets.length > 0,
4358
- message:
4359
- validTargets.length > 0
4360
- ? `Chrome ready with ${validTargets.length} valid tab(s)`
4361
- : "Chrome is running but no valid tabs found. Please navigate to your application.",
4362
- });
4363
- } catch (error) {
4364
- res.json({
4365
- ok: false,
4366
- chromeAvailable: false,
4367
- error: error.message,
4368
- });
4369
- }
4370
- });
4371
-
4372
- /**
4373
- * GET /api/recorder/status
4374
- * Get current recorder session status
4375
- */
4376
- app.get("/api/recorder/status", async (req, res, next) => {
4377
- try {
4378
- const { recorderService } = context;
4379
- if (!recorderService) {
4380
- return res.json({
4381
- ok: true,
4382
- status: { active: false, error: "Recorder service not available" },
4383
- });
4384
- }
4385
-
4386
- const status = recorderService.getStatus();
4387
- res.json({ ok: true, status });
4388
- } catch (error) {
4389
- next(error);
4390
- }
4391
- });
4392
-
4393
- /**
4394
- * GET /api/recorder/steps
4395
- * Get captured steps from current or last session
4396
- */
4397
- app.get("/api/recorder/steps", async (req, res, next) => {
4398
- try {
4399
- const { recorderService } = context;
4400
- if (!recorderService) {
4401
- return res.json({ ok: true, steps: [] });
4402
- }
4403
-
4404
- const steps = recorderService.getSteps();
4405
- res.json({ ok: true, steps });
4406
- } catch (error) {
4407
- next(error);
4408
- }
4409
- });
4410
-
4411
- /**
4412
- * GET /api/recorder/tabs
4413
- * List available Chrome tabs for recording
4414
- */
4415
- app.get("/api/recorder/tabs", async (req, res, next) => {
4416
- try {
4417
- const { getCdpTargets, checkCdpEndpoint } = require("./record-cdp");
4418
-
4419
- // Check if Chrome is available
4420
- const endpointCheck = await checkCdpEndpoint("localhost", 9222);
4421
- if (!endpointCheck.available) {
4422
- return res.json({
4423
- ok: false,
4424
- chromeAvailable: false,
4425
- error: "Chrome is not running with remote debugging enabled",
4426
- tabs: [],
4427
- });
4428
- }
4429
-
4430
- // Get available tabs
4431
- const targets = await getCdpTargets("localhost", 9222);
4432
- const tabs = targets
4433
- .filter((t) => t.type === "page")
4434
- .map((t) => ({
4435
- id: t.id,
4436
- url: t.url,
4437
- title: t.title || t.url,
4438
- // Flag tabs that shouldn't be recorded
4439
- isOurUI:
4440
- t.url.includes("localhost:4300") ||
4441
- t.url.includes("127.0.0.1:4300"),
4442
- isChrome:
4443
- t.url.startsWith("chrome://") ||
4444
- t.url.startsWith("chrome-error://") ||
4445
- t.url === "about:blank",
4446
- }))
4447
- // Sort: real pages first, our UI last
4448
- .sort((a, b) => {
4449
- if (a.isOurUI && !b.isOurUI) return 1;
4450
- if (!a.isOurUI && b.isOurUI) return -1;
4451
- if (a.isChrome && !b.isChrome) return 1;
4452
- if (!a.isChrome && b.isChrome) return -1;
4453
- return 0;
4454
- });
4455
-
4456
- res.json({ ok: true, chromeAvailable: true, tabs });
4457
- } catch (error) {
4458
- console.error("[Recorder API] Get tabs failed:", error);
4459
- res
4460
- .status(500)
4461
- .json({ error: error.message || "Failed to get Chrome tabs" });
4462
- }
4463
- });
4464
-
4465
- /**
4466
- * POST /api/recorder/start
4467
- * Start a new recording session
4468
- */
4469
- app.post("/api/recorder/start", async (req, res, next) => {
4470
- try {
4471
- const { recorderService } = context;
4472
- if (!recorderService) {
4473
- return res
4474
- .status(503)
4475
- .json({ error: "Recorder service not available" });
4476
- }
4477
-
4478
- const { visualKey, title, targetUrl, targetId, scenarioUrl } = req.body;
4479
-
4480
- const result = await recorderService.start({
4481
- visualKey,
4482
- title,
4483
- targetUrl, // Specific URL to record
4484
- targetId, // Specific tab ID to record
4485
- scenarioUrl, // Custom URL to save with the scenario (defaults to targetUrl if not provided)
4486
- uiMode: true, // Important: Skip terminal prompts
4487
- });
4488
-
4489
- res.json({ ok: true, ...result });
4490
- } catch (error) {
4491
- console.error("[Recorder API] Start failed:", error);
4492
- res
4493
- .status(500)
4494
- .json({ error: error.message || "Failed to start recording" });
4495
- }
4496
- });
4497
-
4498
- /**
4499
- * POST /api/recorder/stop
4500
- * Stop the current recording session
4501
- */
4502
- app.post("/api/recorder/stop", async (req, res, next) => {
4503
- try {
4504
- const { recorderService } = context;
4505
- if (!recorderService) {
4506
- return res
4507
- .status(503)
4508
- .json({ error: "Recorder service not available" });
4509
- }
4510
-
4511
- const { save = true, mergeMode = "replace" } = req.body;
4512
-
4513
- const result = await recorderService.stop(save, {
4514
- uiMode: true,
4515
- mergeMode,
4516
- });
4517
-
4518
- res.json({ ok: true, ...result });
4519
- } catch (error) {
4520
- console.error("[Recorder API] Stop failed:", error);
4521
- res
4522
- .status(500)
4523
- .json({ error: error.message || "Failed to stop recording" });
4524
- }
4525
- });
4526
-
4527
- /**
4528
- * POST /api/recorder/capture
4529
- * Capture a screenshot during recording
4530
- */
4531
- app.post("/api/recorder/capture", async (req, res, next) => {
4532
- try {
4533
- const { recorderService } = context;
4534
- if (!recorderService) {
4535
- return res
4536
- .status(503)
4537
- .json({ error: "Recorder service not available" });
4538
- }
4539
-
4540
- const { outputFilename, areaType, selector } = req.body;
4541
-
4542
- const step = await recorderService.capture({
4543
- outputFilename,
4544
- areaType: areaType || "full",
4545
- selector,
4546
- uiMode: true,
4547
- });
4548
-
4549
- res.json({ ok: true, step });
4550
- } catch (error) {
4551
- console.error("[Recorder API] Capture failed:", error);
4552
- res
4553
- .status(500)
4554
- .json({ error: error.message || "Failed to capture screenshot" });
4555
- }
4556
- });
4557
-
4558
- /**
4559
- * DELETE /api/recorder/steps/:index
4560
- * Remove a step at a specific index during recording
4561
- */
4562
- app.delete("/api/recorder/steps/:index", async (req, res, next) => {
4563
- try {
4564
- const { recorderService } = context;
4565
- if (!recorderService) {
4566
- return res
4567
- .status(503)
4568
- .json({ error: "Recorder service not available" });
4569
- }
4570
-
4571
- const index = parseInt(req.params.index, 10);
4572
- if (isNaN(index)) {
4573
- return res.status(400).json({ error: "Invalid step index" });
4574
- }
4575
-
4576
- const result = recorderService.removeStep(index);
4577
- res.json({ ok: true, ...result });
4578
- } catch (error) {
4579
- console.error("[Recorder API] Remove step failed:", error);
4580
- res.status(500).json({ error: error.message || "Failed to remove step" });
4581
- }
4582
- });
4583
-
4584
- /**
4585
- * POST /api/recorder/save-session
4586
- * Save the current Chrome session state (cookies, localStorage) for use in captures
4587
- * This allows captures to run with authenticated sessions without manual login
4588
- */
4589
- app.post("/api/recorder/save-session", async (req, res, next) => {
4590
- try {
4591
- const {
4592
- saveSessionState,
4593
- getDefaultSessionPath,
4594
- } = require("./record-cdp");
4595
-
4596
- const sessionPath = getDefaultSessionPath();
4597
- const result = await saveSessionState(sessionPath);
4598
-
4599
- if (result.success) {
4600
- res.json({
4601
- ok: true,
4602
- path: result.path,
4603
- message:
4604
- "Session saved successfully. Captures will now use your authenticated session.",
4605
- });
4606
- } else {
4607
- res.status(400).json({
4608
- ok: false,
4609
- error: result.error,
4610
- });
4611
- }
4612
- } catch (error) {
4613
- console.error("[Recorder API] Save session failed:", error);
4614
- res
4615
- .status(500)
4616
- .json({ error: error.message || "Failed to save session" });
4617
- }
4618
- });
4619
-
4620
- /**
4621
- * GET /api/recorder/session-status
4622
- * Check if a saved session exists and is valid
4623
- */
4624
- app.get("/api/recorder/session-status", async (req, res, next) => {
4625
- try {
4626
- const { getDefaultSessionPath } = require("./record-cdp");
4627
- const sessionPath = getDefaultSessionPath();
4628
-
4629
- if (fs.existsSync(sessionPath)) {
4630
- const stat = fs.statSync(sessionPath);
4631
- const ageHours = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60);
4632
-
4633
- // Try to parse and get some info
4634
- try {
4635
- const sessionData = fs.readJsonSync(sessionPath);
4636
- res.json({
4637
- ok: true,
4638
- hasSession: true,
4639
- path: sessionPath,
4640
- savedAt: stat.mtime.toISOString(),
4641
- ageHours: Math.round(ageHours * 10) / 10,
4642
- cookieCount: sessionData.cookies?.length || 0,
4643
- originsCount: sessionData.origins?.length || 0,
4644
- isStale: ageHours > 24, // Consider stale after 24 hours
4645
- });
4646
- } catch (parseError) {
4647
- res.json({
4648
- ok: true,
4649
- hasSession: true,
4650
- path: sessionPath,
4651
- error: "Session file is corrupted",
4652
- });
4653
- }
4654
- } else {
4655
- res.json({
4656
- ok: true,
4657
- hasSession: false,
4658
- message:
4659
- "No saved session. Use 'Save Session' in Recorder to capture your authenticated state.",
4660
- });
4661
- }
4662
- } catch (error) {
4663
- console.error("[Recorder API] Session status failed:", error);
4664
- res
4665
- .status(500)
4666
- .json({ error: error.message || "Failed to check session status" });
4667
- }
4668
- });
3876
+ attachRecorderRoutes(app, context);
4669
3877
 
4670
3878
  // Error handler
4671
3879
  app.use(handleError);