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

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 (63) 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-release.js +7 -0
  7. package/src/commands/doctor-target.js +36 -4
  8. package/src/commands/drifts.js +13 -1
  9. package/src/commands/publish.js +183 -21
  10. package/src/commands/pull.js +9 -4
  11. package/src/commands/refresh.js +166 -0
  12. package/src/commands/setup-wizard.js +57 -3
  13. package/src/commands/status.js +22 -2
  14. package/src/commands/variation.js +194 -0
  15. package/src/index.js +190 -10
  16. package/src/lib/api-client.js +61 -35
  17. package/src/lib/auto-update/refresh.js +598 -0
  18. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  19. package/src/lib/auto-update/spec.js +89 -0
  20. package/src/lib/capture-engine.js +76 -2
  21. package/src/lib/capture-script-runner.js +289 -138
  22. package/src/lib/certification.js +23 -1
  23. package/src/lib/compose-context.js +156 -0
  24. package/src/lib/compose-pack.js +42 -0
  25. package/src/lib/compose-runtime.js +34 -0
  26. package/src/lib/compose-upload.js +142 -0
  27. package/src/lib/config.js +2 -2
  28. package/src/lib/dom-capture.js +64 -0
  29. package/src/lib/ensure-browser.js +147 -0
  30. package/src/lib/record-clip.js +83 -3
  31. package/src/lib/record-config.js +0 -4
  32. package/src/lib/release-doctor.js +11 -3
  33. package/src/lib/resolve-targets.js +60 -0
  34. package/src/lib/run-manifest.js +45 -0
  35. package/src/lib/ui-api-helpers.js +118 -0
  36. package/src/lib/ui-api.js +28 -820
  37. package/src/lib/ui-asset-cleanup.js +62 -0
  38. package/src/lib/ui-output-versions.js +165 -0
  39. package/src/lib/ui-recorder-routes.js +341 -0
  40. package/src/lib/ui-scenario-metadata.js +161 -0
  41. package/vendor/compose/dist/auto-update.cjs +5544 -0
  42. package/vendor/compose/dist/auto-update.mjs +5518 -0
  43. package/vendor/compose/dist/capture.cjs +1450 -0
  44. package/vendor/compose/dist/capture.mjs +1416 -0
  45. package/vendor/compose/dist/eligibility.cjs +5331 -0
  46. package/vendor/compose/dist/eligibility.mjs +5313 -0
  47. package/vendor/compose/dist/index.cjs +2046 -0
  48. package/vendor/compose/dist/index.mjs +1997 -0
  49. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  50. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  51. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  52. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  53. package/vendor/compose/dist/render.cjs +558 -0
  54. package/vendor/compose/dist/render.mjs +515 -0
  55. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  56. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  57. package/vendor/compose/dist/verify.cjs +3880 -0
  58. package/vendor/compose/dist/verify.mjs +3858 -0
  59. package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
  60. package/web/manager/dist/index.html +1 -1
  61. package/src/commands/ingest.js +0 -458
  62. package/src/commands/setup.js +0 -165
  63. package/src/lib/playwright-runner.js +0 -252
@@ -8,13 +8,27 @@ const { chromium } = require('playwright');
8
8
  const { updateBrowserMode } = require('./record-browser-injection');
9
9
  const { runPolishedClip } = require('./polished-clip');
10
10
  const { saveScenarioProgress } = require('./record-config');
11
+ const { resolveTargets } = require('./resolve-targets');
12
+ const { captureDomArtifact } = require('./dom-capture');
11
13
 
12
14
  /**
13
15
  * Start clip recording flow
14
16
  * @param {Object} sessionState - Recording session state
15
17
  * @param {Page} page - Playwright page object
16
18
  */
17
- async function startClipRecording(sessionState, page) {
19
+ async function startClipRecording(sessionState, page, config = {}) {
20
+ const metadataRecorder = config.emitMetadata
21
+ ? createClipMetadataRecorder({
22
+ slug: config.slug || sessionState.visualKey,
23
+ captureSize: config.captureSize || { width: 1280, height: 720 },
24
+ })
25
+ : null;
26
+
27
+ if (metadataRecorder) {
28
+ sessionState.logEvent = metadataRecorder.logEvent;
29
+ metadataRecorder.logEvent('workflow_start');
30
+ }
31
+
18
32
  // Ask about container element
19
33
  const { useContainer } = await inquirer.prompt([
20
34
  {
@@ -127,6 +141,10 @@ async function startClipRecording(sessionState, page) {
127
141
 
128
142
  // Set up action replay handler to sync video with real user actions
129
143
  sessionState.replayActionToRecording = async (action, selector, text) => {
144
+ if (metadataRecorder) {
145
+ metadataRecorder.logEvent(action, { selector, text });
146
+ }
147
+
130
148
  try {
131
149
  if (action === 'click') {
132
150
  await recordingPage.click(selector);
@@ -233,6 +251,17 @@ async function startClipRecording(sessionState, page) {
233
251
 
234
252
  sessionState.capturedSteps.push(clipStep);
235
253
  await saveScenarioProgress(sessionState, page, { finalize: false });
254
+
255
+ if (metadataRecorder) {
256
+ await writeClipMetadata({
257
+ page,
258
+ outputDir: config.outputDir || sessionState.outputDir || process.cwd(),
259
+ slug: metadataRecorder.slug,
260
+ captureSize: metadataRecorder.captureSize,
261
+ timeline: metadataRecorder.timeline,
262
+ targets: config.targets,
263
+ });
264
+ }
236
265
 
237
266
  // Clean up temp video
238
267
  fs.removeSync(rawVideoPath);
@@ -242,6 +271,9 @@ async function startClipRecording(sessionState, page) {
242
271
  sessionState.clipEvents = null;
243
272
  sessionState.recordingStart = null;
244
273
  sessionState.stopClipRecording = false;
274
+ if (metadataRecorder) {
275
+ delete sessionState.logEvent;
276
+ }
245
277
 
246
278
  console.log(
247
279
  chalk.green(
@@ -250,6 +282,53 @@ async function startClipRecording(sessionState, page) {
250
282
  );
251
283
  }
252
284
 
285
+ function createClipMetadataRecorder({ slug, captureSize }) {
286
+ const startedAt = Date.now();
287
+ const timeline = [];
288
+
289
+ return {
290
+ slug,
291
+ captureSize,
292
+ timeline,
293
+ logEvent(type, payload = {}) {
294
+ timeline.push({
295
+ tMs: Date.now() - startedAt,
296
+ type,
297
+ payload,
298
+ });
299
+ },
300
+ };
301
+ }
302
+
303
+ async function writeClipMetadata({
304
+ page,
305
+ outputDir,
306
+ slug,
307
+ captureSize,
308
+ timeline,
309
+ targets,
310
+ }) {
311
+ const resolvedTargets = targets ? await resolveTargets(page, targets) : {};
312
+
313
+ // Tier-3: alongside the video, emit a self-contained DOM reconstruction
314
+ // artifact (<slug>.dom.html + sidecars) for eligible screens. Additive and
315
+ // best-effort — it must never break the video path, so failures are swallowed.
316
+ await fs.ensureDir(outputDir);
317
+ const domArtifact = await captureDomArtifact({ page, outputDir, slug });
318
+
319
+ const metadata = {
320
+ slug,
321
+ version: 1,
322
+ captureSize,
323
+ timeline,
324
+ targets: resolvedTargets,
325
+ ...(domArtifact ? { domArtifact } : {}),
326
+ };
327
+ const metadataPath = path.join(outputDir, `${slug}.metadata.json`);
328
+ await fs.writeJson(metadataPath, metadata, { spaces: 2 });
329
+ return metadataPath;
330
+ }
331
+
253
332
  /**
254
333
  * Run subtitle editor mini-server
255
334
  * @param {Array} events - Clip events with timestamps
@@ -338,6 +417,7 @@ function buildClipKey(visualKey, filename) {
338
417
  }
339
418
 
340
419
  module.exports = {
341
- startClipRecording
420
+ createClipMetadataRecorder,
421
+ startClipRecording,
422
+ writeClipMetadata
342
423
  };
343
-
@@ -492,10 +492,6 @@ async function persistFinalScenario(sessionState, page, options = {}) {
492
492
  // Default output configuration for automatic step-by-step image generation
493
493
  const defaultOutput = {
494
494
  format: "step-by-step-images",
495
- highlight: {
496
- color: "rgba(255, 255, 0, 0.5)",
497
- style: "box",
498
- },
499
495
  };
500
496
 
501
497
  // Parse groupPath from session state or existing scenario
@@ -275,13 +275,21 @@ async function runReleaseDoctor(options = {}) {
275
275
  for (const issue of targetDoctor.summary?.advisories || []) {
276
276
  advisories.push({ scope: "target-doctor", ...issue });
277
277
  }
278
- if (!docsAssetMap.skipped) {
278
+ // A stale/mismatched docs asset map (e.g. src/data/reshot-assets.json left
279
+ // behind by an earlier `reshot pull`) describes a generated artifact, not the
280
+ // config being published. It must NOT hard-block a publish of the current
281
+ // config — surface it as an advisory with a concrete remedy instead.
282
+ if (!docsAssetMap.skipped && !docsAssetMap.ok) {
283
+ const remedy =
284
+ docsAssetMap.path
285
+ ? `Re-run \`reshot pull\` to regenerate it, or delete ${docsAssetMap.path}.`
286
+ : "Re-run `reshot pull` to regenerate it, or delete the stale src/data/reshot-assets.json.";
279
287
  for (const issue of docsAssetMap.issues) {
280
- blockingIssues.push({ scope: "docs-asset-map", message: issue });
288
+ advisories.push({ scope: "docs-asset-map", message: `${issue} ${remedy}` });
281
289
  }
282
290
  }
283
291
 
284
- const ok = preflight.ok && targetDoctor.ok && (docsAssetMap.skipped || docsAssetMap.ok);
292
+ const ok = preflight.ok && targetDoctor.ok;
285
293
  const report = {
286
294
  type: "ReleaseDoctorReport",
287
295
  stage: "doctor-release",
@@ -0,0 +1,60 @@
1
+ // resolve-targets.js - Resolve compose target coordinates from a Playwright page
2
+
3
+ async function resolveTargets(page, targets = {}) {
4
+ const resolved = {};
5
+
6
+ for (const [name, config] of Object.entries(targets || {})) {
7
+ const target = normalizeTarget(config);
8
+
9
+ for (const step of target.navigate) {
10
+ if (step.clickText) {
11
+ await page.getByText(step.clickText, { exact: true }).first().click();
12
+ } else if (step.selector) {
13
+ await page.locator(step.selector).first().click();
14
+ }
15
+ if (step.waitMs) {
16
+ await page.waitForTimeout(step.waitMs);
17
+ }
18
+ }
19
+
20
+ const locator = page.locator(target.selector).first();
21
+ await locator.waitFor({ state: 'visible', timeout: target.timeoutMs });
22
+ const box = await locator.boundingBox();
23
+ if (!box) {
24
+ throw new Error(`Could not resolve target "${name}"`);
25
+ }
26
+
27
+ resolved[name] = {
28
+ x: Math.round(box.x),
29
+ y: Math.round(box.y),
30
+ w: Math.round(box.width),
31
+ h: Math.round(box.height),
32
+ };
33
+ }
34
+
35
+ return resolved;
36
+ }
37
+
38
+ function normalizeTarget(config) {
39
+ if (typeof config === 'string') {
40
+ return {
41
+ selector: config,
42
+ navigate: [],
43
+ timeoutMs: 10000,
44
+ };
45
+ }
46
+
47
+ if (!config || typeof config !== 'object' || !config.selector) {
48
+ throw new Error('Target config must be a selector string or an object with selector');
49
+ }
50
+
51
+ return {
52
+ selector: config.selector,
53
+ navigate: Array.isArray(config.navigate) ? config.navigate : [],
54
+ timeoutMs: config.timeoutMs || 10000,
55
+ };
56
+ }
57
+
58
+ module.exports = {
59
+ resolveTargets,
60
+ };
@@ -91,6 +91,50 @@ function getLatestSuccessfulRunManifest() {
91
91
  return null;
92
92
  }
93
93
 
94
+ /**
95
+ * Returns the latest run manifest that has at least one successful scenario,
96
+ * even if the overall run was not fully successful. This prevents falling back
97
+ * to stale manifests when only some scenarios failed.
98
+ *
99
+ * Returns { manifest, isFallback, isPartialSuccess } where:
100
+ * - isFallback: true if the returned manifest is NOT the latest run
101
+ * - isPartialSuccess: true if the manifest has both succeeded and failed scenarios
102
+ */
103
+ function getLatestUsableRunManifest() {
104
+ const latest = readRunManifest(LATEST_RUN_MANIFEST_PATH);
105
+
106
+ if (latest) {
107
+ const successfulScenarios = (latest.scenarios || []).filter(
108
+ (s) => s.success !== false,
109
+ );
110
+ if (successfulScenarios.length > 0) {
111
+ return {
112
+ manifest: latest,
113
+ isFallback: false,
114
+ isPartialSuccess: !latest.success,
115
+ };
116
+ }
117
+ }
118
+
119
+ // Latest has zero successful scenarios — search historical manifests
120
+ for (const manifestPath of listRunManifestPaths()) {
121
+ const manifest = readRunManifest(manifestPath);
122
+ if (!manifest) continue;
123
+ const successfulScenarios = (manifest.scenarios || []).filter(
124
+ (s) => s.success !== false,
125
+ );
126
+ if (successfulScenarios.length > 0) {
127
+ return {
128
+ manifest,
129
+ isFallback: true,
130
+ isPartialSuccess: !manifest.success,
131
+ };
132
+ }
133
+ }
134
+
135
+ return null;
136
+ }
137
+
94
138
  module.exports = {
95
139
  RUN_MANIFEST_DIR,
96
140
  LATEST_RUN_MANIFEST_PATH,
@@ -99,5 +143,6 @@ module.exports = {
99
143
  readRunManifest,
100
144
  listRunManifestPaths,
101
145
  getLatestSuccessfulRunManifest,
146
+ getLatestUsableRunManifest,
102
147
  normalizeScenarioResults,
103
148
  };
@@ -0,0 +1,118 @@
1
+ const path = require("path");
2
+ const config = require("./config");
3
+
4
+ /**
5
+ * Get the platform URL from settings, falling back to production.
6
+ * @param {Object} settings - CLI settings object
7
+ * @returns {string} Platform URL
8
+ */
9
+ function getPlatformUrl(settings) {
10
+ if (settings?.platformUrl) {
11
+ return settings.platformUrl;
12
+ }
13
+
14
+ const envUrl = process.env.RESHOT_API_BASE_URL;
15
+ if (envUrl) {
16
+ return envUrl.replace(/\/api\/?$/, "");
17
+ }
18
+
19
+ return "https://reshot.dev";
20
+ }
21
+
22
+ /**
23
+ * Handle API errors and detect if re-auth is needed.
24
+ * @param {Error} error - The error from API call
25
+ * @param {Object} res - Express response object
26
+ * @returns {Object|null} Response if error was handled, null otherwise
27
+ */
28
+ function handleApiError(error, res) {
29
+ if (config.isAuthError(error)) {
30
+ const errorMsg =
31
+ error.response?.data?.error ||
32
+ error.message ||
33
+ "API key is invalid or expired";
34
+ return res.status(401).json(config.createAuthErrorResponse(errorMsg));
35
+ }
36
+
37
+ return null;
38
+ }
39
+
40
+ /**
41
+ * Generate all possible variant combinations from dimensions.
42
+ * @param {Object} dimensions - Variant dimensions config
43
+ * @param {string[]} dimensionKeys - Which dimensions to include
44
+ * @returns {Array<Object>} Array of variant objects
45
+ */
46
+ function generateVariantCombinations(dimensions, dimensionKeys = []) {
47
+ if (!dimensions || dimensionKeys.length === 0) {
48
+ return [];
49
+ }
50
+
51
+ const dimensionOptions = dimensionKeys
52
+ .map((key) => {
53
+ const dim = dimensions[key];
54
+ if (!dim?.options) return [];
55
+ return Object.keys(dim.options).map((optKey) => ({
56
+ dimension: key,
57
+ option: optKey,
58
+ }));
59
+ })
60
+ .filter((opts) => opts.length > 0);
61
+
62
+ if (dimensionOptions.length === 0) {
63
+ return [];
64
+ }
65
+
66
+ const combinations = cartesian(...dimensionOptions);
67
+
68
+ return combinations.map((combo) => {
69
+ const variant = {};
70
+ for (const { dimension, option } of combo) {
71
+ variant[dimension] = option;
72
+ }
73
+ return variant;
74
+ });
75
+ }
76
+
77
+ function cartesian(...arrays) {
78
+ return arrays.reduce(
79
+ (acc, arr) => acc.flatMap((combo) => arr.map((item) => [...combo, item])),
80
+ [[]],
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Validate a path segment to prevent directory traversal attacks.
86
+ * @param {string} segment - Path segment to validate
87
+ * @returns {boolean} True if safe, false if potentially malicious
88
+ */
89
+ function isValidPathSegment(segment) {
90
+ if (!segment || typeof segment !== "string") return false;
91
+ if (segment === "." || segment === "..") return false;
92
+ if (segment.includes("/") || segment.includes("\\")) return false;
93
+ if (segment.includes("\0")) return false;
94
+ return true;
95
+ }
96
+
97
+ /**
98
+ * Validate that a resolved path stays within the expected base directory.
99
+ * @param {string} resolvedPath - Fully resolved path
100
+ * @param {string} baseDir - Expected base directory
101
+ * @returns {boolean} True if path is within base, false otherwise
102
+ */
103
+ function isPathWithinBase(resolvedPath, baseDir) {
104
+ const normalizedBase = path.resolve(baseDir);
105
+ const normalizedPath = path.resolve(resolvedPath);
106
+ return (
107
+ normalizedPath.startsWith(normalizedBase + path.sep) ||
108
+ normalizedPath === normalizedBase
109
+ );
110
+ }
111
+
112
+ module.exports = {
113
+ generateVariantCombinations,
114
+ getPlatformUrl,
115
+ handleApiError,
116
+ isPathWithinBase,
117
+ isValidPathSegment,
118
+ };