@reshotdev/screenshot 0.0.1-beta.2 → 0.0.1-beta.20

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 (81) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +138 -47
  3. package/package.json +27 -16
  4. package/src/commands/auth.js +159 -30
  5. package/src/commands/capture-dom.js +50 -0
  6. package/src/commands/certify.js +62 -0
  7. package/src/commands/compose.js +220 -0
  8. package/src/commands/doctor-release.js +74 -0
  9. package/src/commands/doctor-target.js +108 -0
  10. package/src/commands/drifts.js +16 -69
  11. package/src/commands/import-tests.js +13 -13
  12. package/src/commands/init.js +16 -277
  13. package/src/commands/publish.js +484 -257
  14. package/src/commands/pull.js +302 -35
  15. package/src/commands/refresh.js +166 -0
  16. package/src/commands/run.js +292 -12
  17. package/src/commands/setup-wizard.js +348 -496
  18. package/src/commands/status.js +334 -126
  19. package/src/commands/sync.js +28 -236
  20. package/src/commands/ui.js +1 -1
  21. package/src/commands/variation.js +194 -0
  22. package/src/commands/verify-publish.js +46 -0
  23. package/src/index.js +383 -118
  24. package/src/lib/api-client.js +172 -60
  25. package/src/lib/auto-update/refresh.js +598 -0
  26. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  27. package/src/lib/auto-update/spec.js +89 -0
  28. package/src/lib/capture-engine.js +179 -9
  29. package/src/lib/capture-script-runner.js +639 -214
  30. package/src/lib/certification.js +887 -0
  31. package/src/lib/compose-context.js +156 -0
  32. package/src/lib/compose-pack.js +42 -0
  33. package/src/lib/compose-runtime.js +34 -0
  34. package/src/lib/compose-upload.js +142 -0
  35. package/src/lib/config.js +186 -81
  36. package/src/lib/dom-capture.js +64 -0
  37. package/src/lib/ensure-browser.js +147 -0
  38. package/src/lib/output-path-template.js +3 -3
  39. package/src/lib/record-cdp.js +288 -16
  40. package/src/lib/record-clip.js +83 -3
  41. package/src/lib/record-config.js +1 -5
  42. package/src/lib/release-doctor.js +321 -0
  43. package/src/lib/resolve-targets.js +60 -0
  44. package/src/lib/run-manifest.js +148 -0
  45. package/src/lib/standalone-mode.js +1 -1
  46. package/src/lib/storage-providers.js +5 -5
  47. package/src/lib/style-engine.js +5 -5
  48. package/src/lib/target-contract.js +292 -0
  49. package/src/lib/ui-api-helpers.js +118 -0
  50. package/src/lib/ui-api.js +31 -824
  51. package/src/lib/ui-asset-cleanup.js +62 -0
  52. package/src/lib/ui-output-versions.js +165 -0
  53. package/src/lib/ui-recorder-routes.js +341 -0
  54. package/src/lib/ui-scenario-metadata.js +161 -0
  55. package/vendor/compose/dist/auto-update.cjs +5544 -0
  56. package/vendor/compose/dist/auto-update.mjs +5518 -0
  57. package/vendor/compose/dist/capture.cjs +1450 -0
  58. package/vendor/compose/dist/capture.mjs +1416 -0
  59. package/vendor/compose/dist/eligibility.cjs +5331 -0
  60. package/vendor/compose/dist/eligibility.mjs +5313 -0
  61. package/vendor/compose/dist/index.cjs +2046 -0
  62. package/vendor/compose/dist/index.mjs +1997 -0
  63. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  64. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  65. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  66. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  67. package/vendor/compose/dist/render.cjs +558 -0
  68. package/vendor/compose/dist/render.mjs +515 -0
  69. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  70. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  71. package/vendor/compose/dist/verify.cjs +3880 -0
  72. package/vendor/compose/dist/verify.mjs +3858 -0
  73. package/web/manager/dist/assets/index-D0S2otug.js +507 -0
  74. package/web/manager/dist/index.html +1 -1
  75. package/src/commands/ci-run.js +0 -123
  76. package/src/commands/ci-setup.js +0 -288
  77. package/src/commands/ingest.js +0 -458
  78. package/src/commands/setup.js +0 -137
  79. package/src/commands/validate-docs.js +0 -529
  80. package/src/lib/playwright-runner.js +0 -252
  81. package/web/manager/dist/assets/index--ZgioErz.js +0 -507
@@ -0,0 +1,89 @@
1
+ // Phase 5 auto-update — local per-composition state ("the stored spec").
2
+ //
3
+ // A composition's source screen (URL + viewport), the accepted baseline's
4
+ // structure signature + reference frame, and any outstanding flagged candidate
5
+ // live on disk so a CI `reshot refresh` is reproducible and idempotent. The
6
+ // reference frame is the prior ACCEPTED render's recapture; it advances only when
7
+ // a refresh publishes, so a flagged redesign leaves the old baseline (and the old
8
+ // live clip) untouched.
9
+
10
+ const fs = require("fs-extra");
11
+ const path = require("path");
12
+
13
+ function baseDir() {
14
+ return (
15
+ process.env.RESHOT_AUTO_UPDATE_DIR ||
16
+ path.join(process.cwd(), ".reshot", "auto-update")
17
+ );
18
+ }
19
+
20
+ function specDir(compositionId) {
21
+ return path.join(baseDir(), compositionId);
22
+ }
23
+
24
+ function specPath(compositionId) {
25
+ return path.join(specDir(compositionId), "spec.json");
26
+ }
27
+
28
+ function referencePngPath(compositionId) {
29
+ return path.join(specDir(compositionId), "reference.png");
30
+ }
31
+
32
+ async function writeSpec(spec) {
33
+ if (!spec || !spec.compositionId) {
34
+ throw new Error("writeSpec requires spec.compositionId");
35
+ }
36
+ await fs.ensureDir(specDir(spec.compositionId));
37
+ await fs.writeJson(specPath(spec.compositionId), spec, { spaces: 2 });
38
+ return spec;
39
+ }
40
+
41
+ async function readSpec(compositionId) {
42
+ const file = specPath(compositionId);
43
+ if (!(await fs.pathExists(file))) {
44
+ throw new Error(
45
+ `No auto-update spec for composition ${compositionId} at ${file}. ` +
46
+ "Register it with `reshot refresh --register` (or seed it) first.",
47
+ );
48
+ }
49
+ return fs.readJson(file);
50
+ }
51
+
52
+ async function listSpecs(projectId) {
53
+ const dir = baseDir();
54
+ if (!(await fs.pathExists(dir))) return [];
55
+ const entries = await fs.readdir(dir);
56
+ const specs = [];
57
+ for (const entry of entries) {
58
+ const file = specPath(entry);
59
+ if (!(await fs.pathExists(file))) continue;
60
+ const spec = await fs.readJson(file);
61
+ if (projectId && spec.projectId !== projectId) continue;
62
+ specs.push(spec);
63
+ }
64
+ // Stable order so CI summaries and idempotence checks are deterministic.
65
+ return specs.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
66
+ }
67
+
68
+ async function readReferencePng(compositionId) {
69
+ const file = referencePngPath(compositionId);
70
+ if (!(await fs.pathExists(file))) return null;
71
+ return fs.readFile(file);
72
+ }
73
+
74
+ async function writeReferencePng(compositionId, buffer) {
75
+ await fs.ensureDir(specDir(compositionId));
76
+ await fs.writeFile(referencePngPath(compositionId), buffer);
77
+ }
78
+
79
+ module.exports = {
80
+ baseDir,
81
+ specDir,
82
+ specPath,
83
+ referencePngPath,
84
+ writeSpec,
85
+ readSpec,
86
+ listSpecs,
87
+ readReferencePng,
88
+ writeReferencePng,
89
+ };
@@ -6,6 +6,7 @@ const path = require("path");
6
6
  const fs = require("fs-extra");
7
7
  const chalk = require("chalk");
8
8
  const { buildLaunchOptions } = require("./ci-detect");
9
+ const { launchChromium } = require("./ensure-browser");
9
10
  const {
10
11
  applyVariantToPage,
11
12
  applyStorageAndReload,
@@ -20,7 +21,7 @@ const {
20
21
  scaleRegionByDPR,
21
22
  isSharpAvailable,
22
23
  } = require("./image-crop");
23
- const { sanitizeStorageState } = require("./record-cdp");
24
+ const { sanitizeStorageState, assessSessionHealth } = require("./record-cdp");
24
25
  const {
25
26
  injectPrivacyMasking,
26
27
  removePrivacyMasking,
@@ -98,6 +99,9 @@ class CaptureEngine {
98
99
  this.capturedAssets = [];
99
100
  this.logger = options.logger || console.log;
100
101
  this.headless = options.headless !== false; // Default to headless
102
+ this.injectWorkspaceStore = options.injectWorkspaceStore !== false;
103
+ this.diagnostics = [];
104
+ this.sessionHealth = null;
101
105
 
102
106
  // Storage state path for authenticated sessions
103
107
  // If provided, loads cookies/localStorage from file to preserve auth state
@@ -142,6 +146,13 @@ class CaptureEngine {
142
146
  // Style configuration (image beautification post-capture)
143
147
  this.styleConfig = options.styleConfig || null;
144
148
 
149
+ // DOM scene (MHTML) capture toggle — emits a self-contained Chromium
150
+ // MHTML bundle alongside each PNG so variations can be rendered from
151
+ // the captured DOM without re-running the live app. Defaults to ON;
152
+ // opt out via reshot.config.json -> { domScene: false } or per-scenario
153
+ // -> { domScene: false }.
154
+ this.domSceneEnabled = options.domScene !== false;
155
+
145
156
  // Legacy support for old variant format
146
157
  if (!this.variantConfig && options.variant) {
147
158
  this.variantConfig = this._convertLegacyVariant(options.variant);
@@ -226,6 +237,36 @@ class CaptureEngine {
226
237
  }
227
238
  this.logger(chalk.gray(` → Using pre-loaded auth session`));
228
239
  } else if (this.storageStatePath && fs.existsSync(this.storageStatePath)) {
240
+ this.sessionHealth = assessSessionHealth(
241
+ this.storageStatePath,
242
+ this.baseUrl,
243
+ );
244
+
245
+ if (!this.sessionHealth.compatible) {
246
+ this.logger(
247
+ chalk.yellow(
248
+ ` ⚠ Skipping cached auth session because it does not match ${this.baseUrl || "the current target"}`,
249
+ ),
250
+ );
251
+ for (const issue of this.sessionHealth.issues) {
252
+ this.logger(chalk.gray(` ${issue}`));
253
+ }
254
+ this.diagnostics.push({
255
+ type: "auth-session-mismatch",
256
+ sessionPath: this.storageStatePath,
257
+ issues: [...this.sessionHealth.issues],
258
+ });
259
+ return contextOptions;
260
+ }
261
+
262
+ if (this.sessionHealth.stale) {
263
+ this.logger(
264
+ chalk.yellow(
265
+ ` ⚠ Cached auth session is ${this.sessionHealth.ageMinutes}m old; validating it during startup.`,
266
+ ),
267
+ );
268
+ }
269
+
229
270
  // Read and sanitize instead of passing raw file path (Playwright would read it unsanitized)
230
271
  try {
231
272
  const rawState = JSON.parse(fs.readFileSync(this.storageStatePath, "utf-8"));
@@ -245,6 +286,10 @@ class CaptureEngine {
245
286
  )}`
246
287
  )
247
288
  );
289
+
290
+ for (const warning of this.sessionHealth.warnings) {
291
+ this.logger(chalk.gray(` ${warning}`));
292
+ }
248
293
  }
249
294
 
250
295
  return contextOptions;
@@ -255,9 +300,9 @@ class CaptureEngine {
255
300
 
256
301
  const contextOptions = this._buildContextOptions();
257
302
 
258
- this.browser = await chromium.launch(buildLaunchOptions({
303
+ this.browser = await launchChromium(chromium, buildLaunchOptions({
259
304
  headless: this.headless,
260
- }));
305
+ }), this.logger);
261
306
  this.context = await this.browser.newContext(contextOptions);
262
307
  this.page = await this.context.newPage();
263
308
 
@@ -289,7 +334,9 @@ class CaptureEngine {
289
334
  logVariantSummary(this.variantConfig, this.logger);
290
335
  }
291
336
 
292
- await this._injectWorkspaceStore();
337
+ if (this.injectWorkspaceStore) {
338
+ await this._injectWorkspaceStore();
339
+ }
293
340
 
294
341
  // Inject privacy masking CSS (after variant injection, before captures)
295
342
  this._privacyInjectionOk = true;
@@ -306,24 +353,76 @@ class CaptureEngine {
306
353
  this._authResponseDetected = false;
307
354
  this.page.on("response", (response) => {
308
355
  const status = response.status();
356
+ const url = response.url();
309
357
  if (
310
358
  (status === 401 || status === 403) &&
311
359
  response.request().resourceType() === "document"
312
360
  ) {
313
361
  this._authResponseDetected = true;
314
362
  }
363
+
364
+ if (status >= 400 && response.request().resourceType() === "document") {
365
+ this._recordDiagnostic("response_error", "error", {
366
+ url,
367
+ status,
368
+ resourceType: response.request().resourceType(),
369
+ });
370
+ }
315
371
  });
316
372
 
317
373
  // Set up error handling
318
374
  this.page.on("pageerror", (err) => {
319
375
  const firstLine = (err.message || '').split('\n')[0].slice(0, 200);
376
+ this._recordDiagnostic("pageerror", "error", {
377
+ message: err.message || String(err),
378
+ });
320
379
  this.logger(chalk.yellow(` [Page Error] ${firstLine}`));
321
380
  });
381
+ this.page.on("console", (message) => {
382
+ const type = message.type();
383
+ const text = message.text();
384
+ const severity =
385
+ type === "error"
386
+ ? "error"
387
+ : type === "warning"
388
+ ? "warning"
389
+ : "info";
390
+
391
+ if (severity === "error" || severity === "warning") {
392
+ this._recordDiagnostic("console", severity, {
393
+ message: text,
394
+ consoleType: type,
395
+ cspViolation: /content security policy|csp/i.test(text),
396
+ });
397
+ }
398
+ });
399
+ this.page.on("requestfailed", (request) => {
400
+ this._recordDiagnostic("requestfailed", "error", {
401
+ url: request.url(),
402
+ method: request.method(),
403
+ resourceType: request.resourceType(),
404
+ message: request.failure()?.errorText || "Request failed",
405
+ });
406
+ });
322
407
 
323
408
  this.logger(chalk.green(" ✔ Browser initialized"));
324
409
  return this;
325
410
  }
326
411
 
412
+ _recordDiagnostic(kind, severity, details = {}) {
413
+ this.diagnostics.push({
414
+ id: `${Date.now()}-${this.diagnostics.length + 1}`,
415
+ kind,
416
+ severity,
417
+ ...details,
418
+ capturedAt: new Date().toISOString(),
419
+ });
420
+ }
421
+
422
+ getDiagnostics() {
423
+ return [...this.diagnostics];
424
+ }
425
+
327
426
  /**
328
427
  * Hide development overlays (Next.js devtools, Vercel toolbar, etc.)
329
428
  * Injects CSS to hide common development UI elements before each navigation
@@ -412,11 +511,11 @@ class CaptureEngine {
412
511
  projectId = settings.urlVariables?.PROJECT_ID;
413
512
  // 2. Check settings projectId
414
513
  if (!projectId) projectId = settings.projectId;
415
- // 3. Check docsync.config.json urlVariables
514
+ // 3. Check reshot.config.json urlVariables
416
515
  if (!projectId) {
417
516
  try {
418
- const docsyncConfig = config.readConfig() || {};
419
- projectId = docsyncConfig.urlVariables?.PROJECT_ID;
517
+ const reshotConfig = config.readConfig() || {};
518
+ projectId = reshotConfig.urlVariables?.PROJECT_ID;
420
519
  } catch (_e) {
421
520
  // Config may not exist
422
521
  }
@@ -512,10 +611,15 @@ class CaptureEngine {
512
611
  }
513
612
 
514
613
  // Check for auth redirect after navigation (URL patterns + HTTP 401/403)
614
+ // Skip the check if the scenario explicitly targeted this URL (e.g. capturing /login)
515
615
  const currentUrl = this.page.url();
616
+ const targetPath = url.startsWith("http") ? new URL(url).pathname : url;
617
+ const currentPath = (() => { try { return new URL(currentUrl).pathname; } catch { return currentUrl; } })();
618
+ const isIntentionalTarget = currentPath === targetPath;
516
619
  const isAuthRedirect =
517
- isAuthRedirectUrl(currentUrl, this._customAuthPatterns) ||
518
- this._authResponseDetected;
620
+ !isIntentionalTarget &&
621
+ (isAuthRedirectUrl(currentUrl, this._customAuthPatterns) ||
622
+ this._authResponseDetected);
519
623
  if (isAuthRedirect) {
520
624
  const errorMsg = `Auth redirect detected: navigated to ${currentUrl}. Session may be expired. Re-run \`reshot record\` to refresh session, or export a fresh Playwright storage state to .reshot/auth-state.json.`;
521
625
  this.logger(chalk.red(` ✖ ${errorMsg}`));
@@ -527,6 +631,32 @@ class CaptureEngine {
527
631
  // Wait for network to settle
528
632
  await this._waitForStability();
529
633
 
634
+ // Post-stability auth redirect check: catches SPA redirects that happen
635
+ // after client-side JS has executed (the pre-stability check at domcontentloaded
636
+ // may miss these since JS hasn't finished routing yet)
637
+ if (!isIntentionalTarget) {
638
+ const postStabilityUrl = this.page.url();
639
+ const postStabilityRedirect =
640
+ isAuthRedirectUrl(postStabilityUrl, this._customAuthPatterns) ||
641
+ this._authResponseDetected;
642
+ if (postStabilityRedirect) {
643
+ const errorMsg = `Auth redirect detected after page load: navigated to ${postStabilityUrl}. Session may be expired. Re-run \`reshot record\` to refresh session.`;
644
+ this.logger(chalk.red(` ✖ ${errorMsg}`));
645
+ throw new Error(errorMsg);
646
+ }
647
+ // Also check DOM for login forms (SPA may render login UI without changing URL)
648
+ const hasLoginForm = await this.page.evaluate(() => {
649
+ const h = document.querySelector("h1, h2");
650
+ return h && /sign\s*in|log\s*in/i.test(h.textContent);
651
+ }).catch(() => false);
652
+ if (hasLoginForm) {
653
+ const errorMsg = `Login form detected after page load at ${postStabilityUrl}. Session may be expired. Re-run \`reshot record\` to refresh session.`;
654
+ this.logger(chalk.red(` ✖ ${errorMsg}`));
655
+ throw new Error(errorMsg);
656
+ }
657
+ this._authResponseDetected = false;
658
+ }
659
+
530
660
  // Additional wait for theme/variants to fully apply
531
661
  // This handles CSS transitions and async re-renders
532
662
  if (this.variantConfig && this.variantConfig.injections?.length > 0) {
@@ -1011,10 +1141,50 @@ class CaptureEngine {
1011
1141
  // Write the final buffer to file
1012
1142
  await fs.writeFile(outputPath, finalBuffer);
1013
1143
 
1144
+ // ── DOM scene capture (sidecar MHTML) ──────────────────────────────
1145
+ // Capture a self-contained MHTML bundle of the page at the same moment
1146
+ // as the PNG. The bundle re-renders in any Chromium browser without
1147
+ // network access and is the source of truth for variations: marketing
1148
+ // can mutate the captured DOM (swap copy, hide chrome, recrop, rebrand
1149
+ // tenant names) and render new outputs without re-running Playwright
1150
+ // against the live app. Opt out per-scenario or globally via
1151
+ // reshot.config.json -> { domScene: false }.
1152
+ //
1153
+ // The MHTML capture is best-effort — failures are logged but do not
1154
+ // fail the scenario, since the primary PNG already succeeded.
1155
+ let domScenePath = null;
1156
+ let domSceneBytes = null;
1157
+ if (this.domSceneEnabled) {
1158
+ try {
1159
+ const cdp = await this.page.context().newCDPSession(this.page);
1160
+ const { data: mhtml } = await cdp.send("Page.captureSnapshot", {
1161
+ format: "mhtml",
1162
+ });
1163
+ domScenePath = outputPath.replace(/\.png$/i, ".mhtml");
1164
+ await fs.writeFile(domScenePath, mhtml);
1165
+ domSceneBytes = Buffer.byteLength(mhtml, "utf8");
1166
+ this.logger(
1167
+ chalk.gray(
1168
+ ` ✓ DOM scene: ${path.basename(domScenePath)} (${(domSceneBytes / 1024).toFixed(0)} KB)`,
1169
+ ),
1170
+ );
1171
+ } catch (err) {
1172
+ this.logger(
1173
+ chalk.yellow(
1174
+ ` ⚠ DOM scene capture skipped: ${err.message || err}`,
1175
+ ),
1176
+ );
1177
+ domScenePath = null;
1178
+ domSceneBytes = null;
1179
+ }
1180
+ }
1181
+
1014
1182
  // Record asset metadata
1015
1183
  this.capturedAssets.push({
1016
1184
  name,
1017
1185
  path: outputPath,
1186
+ domScenePath,
1187
+ domSceneBytes,
1018
1188
  description,
1019
1189
  capturedAt: new Date().toISOString(),
1020
1190
  viewport: this.viewport,