@reshotdev/screenshot 0.0.1-beta.11 → 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.
- package/LICENSE +1 -1
- package/README.md +84 -51
- package/package.json +20 -16
- package/src/commands/auth.js +38 -8
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-target.js +36 -4
- package/src/commands/drifts.js +13 -1
- package/src/commands/publish.js +137 -12
- package/src/commands/pull.js +13 -8
- package/src/commands/refresh.js +166 -0
- package/src/commands/setup-wizard.js +35 -2
- package/src/commands/status.js +22 -2
- package/src/commands/variation.js +194 -0
- package/src/index.js +189 -47
- package/src/lib/api-client.js +61 -35
- package/src/lib/auto-update/refresh.js +598 -0
- package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
- package/src/lib/auto-update/spec.js +89 -0
- package/src/lib/capture-engine.js +73 -0
- package/src/lib/capture-script-runner.js +280 -134
- package/src/lib/certification.js +23 -1
- package/src/lib/compose-context.js +156 -0
- package/src/lib/compose-pack.js +42 -0
- package/src/lib/compose-runtime.js +34 -0
- package/src/lib/compose-upload.js +142 -0
- package/src/lib/config.js +5 -5
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/output-path-template.js +3 -3
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +0 -4
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +45 -0
- package/src/lib/storage-providers.js +1 -1
- package/src/lib/style-engine.js +5 -5
- package/src/lib/ui-api-helpers.js +118 -0
- package/src/lib/ui-api.js +28 -820
- package/src/lib/ui-asset-cleanup.js +62 -0
- package/src/lib/ui-output-versions.js +165 -0
- package/src/lib/ui-recorder-routes.js +341 -0
- package/src/lib/ui-scenario-metadata.js +161 -0
- package/vendor/compose/dist/auto-update.cjs +5544 -0
- package/vendor/compose/dist/auto-update.mjs +5518 -0
- package/vendor/compose/dist/capture.cjs +1450 -0
- package/vendor/compose/dist/capture.mjs +1416 -0
- package/vendor/compose/dist/eligibility.cjs +5331 -0
- package/vendor/compose/dist/eligibility.mjs +5313 -0
- package/vendor/compose/dist/index.cjs +2046 -0
- package/vendor/compose/dist/index.mjs +1997 -0
- package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
- package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
- package/vendor/compose/dist/jsx-runtime.cjs +58 -0
- package/vendor/compose/dist/jsx-runtime.mjs +31 -0
- package/vendor/compose/dist/render.cjs +558 -0
- package/vendor/compose/dist/render.mjs +515 -0
- package/vendor/compose/dist/verify-cli.cjs +3806 -0
- package/vendor/compose/dist/verify-cli.mjs +3812 -0
- package/vendor/compose/dist/verify.cjs +3880 -0
- package/vendor/compose/dist/verify.mjs +3858 -0
- package/web/manager/dist/assets/{index-D2qqcFNN.js → index-D0S2otug.js} +56 -56
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ci-run.js +0 -178
- package/src/commands/ci-setup.js +0 -288
- package/src/commands/ingest.js +0 -458
- package/src/commands/setup.js +0 -165
- 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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
|
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
|
-
|
|
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);
|