@onlook/storybook-plugin 0.4.0-beta.11 → 0.4.0-beta.12

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/dist/cli/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { createRequire } from 'node:module';
3
3
  import { command, string, boolean, run } from '@drizzle-team/brocli';
4
4
  import { execFile, spawn } from 'child_process';
5
- import fs, { readFileSync, existsSync } from 'fs';
6
5
  import path, { resolve, join, dirname } from 'path';
7
6
  import crypto from 'crypto';
7
+ import fs, { existsSync, readFileSync } from 'fs';
8
8
  import { createRequire as createRequire$1 } from 'module';
9
9
  import { promisify } from 'util';
10
10
  import { chromium } from 'playwright';
@@ -143,6 +143,77 @@ async function closeBrowser() {
143
143
  console.error("Error closing browser:", error);
144
144
  }
145
145
  }
146
+
147
+ // src/containment-contract/containment-contract.ts
148
+ var ONLOOK_RENDER_STATE_ATTR = "data-onlook-render-state";
149
+ var ONLOOK_CONTAINED_CARD_ATTR = "data-onlook-contained-card";
150
+
151
+ // src/screenshot-service/utils/screenshot/screenshot.ts
152
+ function classifyPreviewSnapshot(s) {
153
+ if (s.text.includes("Sorry, something went wrong")) return "errored";
154
+ if (s.text.includes("Couldn't find story")) return "errored";
155
+ if (s.hasContainedCard) return "contained";
156
+ if (s.renderStateAttr === "contained") return "contained";
157
+ if (s.renderStateAttr === "rendered") return "rendered";
158
+ if (s.text.length > 0) return "rendered";
159
+ if (s.hasSizedElement) return "rendered";
160
+ return "pending";
161
+ }
162
+ async function detectRenderState(page, opts = {}) {
163
+ const pollMs = opts.pollMs ?? 250;
164
+ const settleMs = opts.settleMs ?? 4e3;
165
+ const blankStablePolls = opts.blankStablePolls ?? 6;
166
+ const hardCapMs = opts.hardCapMs ?? 2e4;
167
+ const now = opts.now ?? Date.now;
168
+ const start = now();
169
+ let emptyStreak = 0;
170
+ const snapshot = () => page.evaluate(
171
+ ({ stateAttr, cardAttr }) => {
172
+ const root = document.getElementById("storybook-root") ?? document.body;
173
+ const text = (root?.textContent ?? "").trim();
174
+ let hasSizedElement = false;
175
+ if (root) {
176
+ const all = root.querySelectorAll("*");
177
+ for (let i = 0; i < all.length; i++) {
178
+ const rect = all[i]?.getBoundingClientRect();
179
+ if (rect && rect.width > 0 && rect.height > 0) {
180
+ hasSizedElement = true;
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ return {
186
+ text,
187
+ hasContainedCard: Boolean(root?.querySelector(`[${cardAttr}]`)),
188
+ renderStateAttr: root?.getAttribute(stateAttr) ?? null,
189
+ hasSizedElement
190
+ };
191
+ },
192
+ { stateAttr: ONLOOK_RENDER_STATE_ATTR, cardAttr: ONLOOK_CONTAINED_CARD_ATTR }
193
+ );
194
+ while (true) {
195
+ let state;
196
+ let threw = false;
197
+ try {
198
+ state = classifyPreviewSnapshot(await snapshot());
199
+ } catch {
200
+ state = "pending";
201
+ threw = true;
202
+ }
203
+ if (state === "rendered" || state === "contained" || state === "errored") {
204
+ return state;
205
+ }
206
+ const elapsed = now() - start;
207
+ if (threw) {
208
+ emptyStreak = 0;
209
+ } else if (elapsed >= settleMs) {
210
+ emptyStreak += 1;
211
+ if (emptyStreak >= blankStablePolls) return "blank";
212
+ }
213
+ if (elapsed >= hardCapMs) return "timedOut";
214
+ await page.waitForTimeout(pollMs);
215
+ }
216
+ }
146
217
  function getScreenshotPath(storyId, theme) {
147
218
  const storyDir = path.join(SCREENSHOTS_DIR, storyId);
148
219
  return path.join(storyDir, `${theme}.png`);
@@ -151,7 +222,7 @@ function screenshotExists(storyId, theme) {
151
222
  const screenshotPath = getScreenshotPath(storyId, theme);
152
223
  return fs.existsSync(screenshotPath);
153
224
  }
154
- async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
225
+ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", loadTimeoutMs = 9e4, renderTimeoutMs = 2e4) {
155
226
  const browser = await getBrowser();
156
227
  const context = await browser.newContext({
157
228
  viewport: { width, height },
@@ -160,13 +231,14 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
160
231
  const page = await context.newPage();
161
232
  try {
162
233
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
163
- await page.goto(url, { timeout: timeoutMs });
164
- await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
165
- await page.waitForLoadState("load", { timeout: timeoutMs });
234
+ await page.goto(url, { timeout: loadTimeoutMs });
235
+ await page.waitForLoadState("domcontentloaded", { timeout: loadTimeoutMs });
236
+ await page.waitForLoadState("load", { timeout: loadTimeoutMs });
166
237
  try {
167
238
  await page.waitForLoadState("networkidle", { timeout: 5e3 });
168
239
  } catch {
169
240
  }
241
+ const renderState = await detectRenderState(page, { hardCapMs: renderTimeoutMs });
170
242
  await page.evaluate(() => document.fonts.ready);
171
243
  try {
172
244
  await page.evaluate(async () => {
@@ -228,7 +300,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
228
300
  } else {
229
301
  screenshotBuffer = await page.screenshot({ type: "png" });
230
302
  }
231
- return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
303
+ return { buffer: screenshotBuffer, boundingBox: resultBoundingBox, renderState };
232
304
  } finally {
233
305
  await context.close();
234
306
  }
@@ -431,36 +503,54 @@ function detectPackageManager(repoRoot) {
431
503
  }
432
504
  return "npm";
433
505
  }
506
+ function getStorybookScript(storybookDir) {
507
+ const packageJsonPath = join(storybookDir, "package.json");
508
+ if (!existsSync(packageJsonPath)) {
509
+ return null;
510
+ }
511
+ try {
512
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
513
+ return packageJson.scripts?.storybook ?? null;
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
434
518
 
435
519
  // src/cli/generate-screenshots/generate-screenshots.ts
520
+ function execStorybookCommand(pm) {
521
+ const devFlags = ["dev", "-p", "6006", "--no-open"];
522
+ switch (pm) {
523
+ case "pnpm":
524
+ return { command: "pnpm", args: ["exec", "storybook", ...devFlags] };
525
+ case "yarn":
526
+ return { command: "yarn", args: ["exec", "storybook", ...devFlags] };
527
+ case "bun":
528
+ return { command: "bunx", args: ["storybook", ...devFlags] };
529
+ default:
530
+ return { command: "npx", args: ["storybook", ...devFlags] };
531
+ }
532
+ }
436
533
  function getStorybookCommand(storybookDir) {
437
534
  const repoRoot = findRepoRoot(storybookDir);
438
535
  const pm = repoRoot ? detectPackageManager(repoRoot) : "npm";
439
536
  console.log(
440
537
  `\u{1F4E6} Detected package manager: ${pm}${repoRoot ? ` (from ${repoRoot})` : ""}`
441
538
  );
442
- let extraArgs = [];
443
- try {
444
- const pkgPath = resolve(storybookDir, "package.json");
445
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
446
- const script = pkg.scripts?.storybook ?? "";
447
- const hasPort = script.includes("-p ") || script.includes("--port");
448
- const hasNoOpen = script.includes("--no-open");
449
- if (!hasPort || !hasNoOpen) {
450
- const needsSeparator = pm === "npm" || pm === "pnpm";
451
- const flags = [];
452
- if (!hasPort) flags.push("-p", "6006");
453
- if (!hasNoOpen) flags.push("--no-open");
454
- extraArgs = needsSeparator && flags.length > 0 ? ["--", ...flags] : flags;
455
- }
456
- } catch {
457
- const separator = pm === "npm" || pm === "pnpm" ? ["--"] : [];
458
- extraArgs = [...separator, "-p", "6006", "--no-open"];
539
+ const memberScript = getStorybookScript(storybookDir);
540
+ const rootScript = !memberScript && repoRoot && repoRoot !== storybookDir ? getStorybookScript(repoRoot) : null;
541
+ const [script, scriptDir] = memberScript ? [memberScript, storybookDir] : rootScript && repoRoot ? [rootScript, repoRoot] : [null, storybookDir];
542
+ if (!script) {
543
+ console.log("\u26A0\uFE0F No `storybook` script found; invoking the binary directly");
544
+ return { ...execStorybookCommand(pm), cwd: storybookDir };
459
545
  }
460
- return {
461
- command: pm,
462
- args: ["run", "storybook", ...extraArgs]
463
- };
546
+ const hasPort = script.includes("-p ") || script.includes("--port");
547
+ const hasNoOpen = script.includes("--no-open");
548
+ const flags = [];
549
+ if (!hasPort) flags.push("-p", "6006");
550
+ if (!hasNoOpen) flags.push("--no-open");
551
+ const needsSeparator = pm === "npm" || pm === "pnpm";
552
+ const extraArgs = needsSeparator && flags.length > 0 ? ["--", ...flags] : flags;
553
+ return { command: pm, args: ["run", "storybook", ...extraArgs], cwd: scriptDir };
464
554
  }
465
555
  async function isStorybookRunning(url) {
466
556
  try {
@@ -575,8 +665,8 @@ async function generateScreenshots(options = {}) {
575
665
  if (alreadyRunning) {
576
666
  console.log("\u2705 Storybook is already running");
577
667
  } else {
578
- const { command: command2, args } = getStorybookCommand(storybookDir);
579
- storybookProcess = await startStorybook(command2, args, storybookDir);
668
+ const { command: command2, args, cwd } = getStorybookCommand(storybookDir);
669
+ storybookProcess = await startStorybook(command2, args, cwd);
580
670
  weStartedStorybook = true;
581
671
  console.log("\u23F3 Waiting for Storybook to finish compiling...");
582
672
  await waitForStorybookReady(url);
package/dist/index.js CHANGED
@@ -509,11 +509,76 @@ async function getBrowser() {
509
509
  }
510
510
  return browserPromise;
511
511
  }
512
+ function classifyPreviewSnapshot(s) {
513
+ if (s.text.includes("Sorry, something went wrong")) return "errored";
514
+ if (s.text.includes("Couldn't find story")) return "errored";
515
+ if (s.hasContainedCard) return "contained";
516
+ if (s.renderStateAttr === "contained") return "contained";
517
+ if (s.renderStateAttr === "rendered") return "rendered";
518
+ if (s.text.length > 0) return "rendered";
519
+ if (s.hasSizedElement) return "rendered";
520
+ return "pending";
521
+ }
522
+ async function detectRenderState(page, opts = {}) {
523
+ const pollMs = opts.pollMs ?? 250;
524
+ const settleMs = opts.settleMs ?? 4e3;
525
+ const blankStablePolls = opts.blankStablePolls ?? 6;
526
+ const hardCapMs = opts.hardCapMs ?? 2e4;
527
+ const now = opts.now ?? Date.now;
528
+ const start = now();
529
+ let emptyStreak = 0;
530
+ const snapshot = () => page.evaluate(
531
+ ({ stateAttr, cardAttr }) => {
532
+ const root = document.getElementById("storybook-root") ?? document.body;
533
+ const text = (root?.textContent ?? "").trim();
534
+ let hasSizedElement = false;
535
+ if (root) {
536
+ const all = root.querySelectorAll("*");
537
+ for (let i = 0; i < all.length; i++) {
538
+ const rect = all[i]?.getBoundingClientRect();
539
+ if (rect && rect.width > 0 && rect.height > 0) {
540
+ hasSizedElement = true;
541
+ break;
542
+ }
543
+ }
544
+ }
545
+ return {
546
+ text,
547
+ hasContainedCard: Boolean(root?.querySelector(`[${cardAttr}]`)),
548
+ renderStateAttr: root?.getAttribute(stateAttr) ?? null,
549
+ hasSizedElement
550
+ };
551
+ },
552
+ { stateAttr: ONLOOK_RENDER_STATE_ATTR, cardAttr: ONLOOK_CONTAINED_CARD_ATTR }
553
+ );
554
+ while (true) {
555
+ let state;
556
+ let threw = false;
557
+ try {
558
+ state = classifyPreviewSnapshot(await snapshot());
559
+ } catch {
560
+ state = "pending";
561
+ threw = true;
562
+ }
563
+ if (state === "rendered" || state === "contained" || state === "errored") {
564
+ return state;
565
+ }
566
+ const elapsed = now() - start;
567
+ if (threw) {
568
+ emptyStreak = 0;
569
+ } else if (elapsed >= settleMs) {
570
+ emptyStreak += 1;
571
+ if (emptyStreak >= blankStablePolls) return "blank";
572
+ }
573
+ if (elapsed >= hardCapMs) return "timedOut";
574
+ await page.waitForTimeout(pollMs);
575
+ }
576
+ }
512
577
  function getScreenshotPath(storyId, theme) {
513
578
  const storyDir = path7.join(SCREENSHOTS_DIR, storyId);
514
579
  return path7.join(storyDir, `${theme}.png`);
515
580
  }
516
- async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
581
+ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", loadTimeoutMs = 9e4, renderTimeoutMs = 2e4) {
517
582
  const browser = await getBrowser();
518
583
  const context = await browser.newContext({
519
584
  viewport: { width, height },
@@ -522,13 +587,14 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
522
587
  const page = await context.newPage();
523
588
  try {
524
589
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
525
- await page.goto(url, { timeout: timeoutMs });
526
- await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
527
- await page.waitForLoadState("load", { timeout: timeoutMs });
590
+ await page.goto(url, { timeout: loadTimeoutMs });
591
+ await page.waitForLoadState("domcontentloaded", { timeout: loadTimeoutMs });
592
+ await page.waitForLoadState("load", { timeout: loadTimeoutMs });
528
593
  try {
529
594
  await page.waitForLoadState("networkidle", { timeout: 5e3 });
530
595
  } catch {
531
596
  }
597
+ const renderState = await detectRenderState(page, { hardCapMs: renderTimeoutMs });
532
598
  await page.evaluate(() => document.fonts.ready);
533
599
  try {
534
600
  await page.evaluate(async () => {
@@ -590,7 +656,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
590
656
  } else {
591
657
  screenshotBuffer = await page.screenshot({ type: "png" });
592
658
  }
593
- return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
659
+ return { buffer: screenshotBuffer, boundingBox: resultBoundingBox, renderState };
594
660
  } finally {
595
661
  await context.close();
596
662
  }
@@ -795,6 +861,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
795
861
  const page = await context.newPage();
796
862
  const logs = [];
797
863
  const pageErrors = [];
864
+ const failedRequests = [];
798
865
  page.on("console", (msg) => {
799
866
  const level = msg.type();
800
867
  logs.push({
@@ -806,6 +873,28 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
806
873
  page.on("pageerror", (err) => {
807
874
  pageErrors.push(err.message);
808
875
  });
876
+ page.on("response", (res) => {
877
+ const status = res.status();
878
+ if (status >= 400) {
879
+ const req = res.request();
880
+ failedRequests.push({
881
+ url: res.url(),
882
+ status,
883
+ method: req.method(),
884
+ resourceType: req.resourceType(),
885
+ failure: null
886
+ });
887
+ }
888
+ });
889
+ page.on("requestfailed", (req) => {
890
+ failedRequests.push({
891
+ url: req.url(),
892
+ status: null,
893
+ method: req.method(),
894
+ resourceType: req.resourceType(),
895
+ failure: req.failure()?.errorText ?? "request failed"
896
+ });
897
+ });
809
898
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
810
899
  try {
811
900
  await page.goto(url, { timeout: timeoutMs });
@@ -816,7 +905,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
816
905
  } catch {
817
906
  }
818
907
  await new Promise((r) => setTimeout(r, 1e3));
819
- return { logs, pageErrors, url };
908
+ return { logs, pageErrors, failedRequests, url };
820
909
  } finally {
821
910
  await context.close();
822
911
  }
@@ -1148,9 +1237,11 @@ var serveMetadataAndScreenshots = (req, res, next) => {
1148
1237
  res.end(JSON.stringify({ error: 'theme must be "light" or "dark"' }));
1149
1238
  return;
1150
1239
  }
1151
- captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer }) => {
1240
+ captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer, renderState }) => {
1152
1241
  res.setHeader("Content-Type", "image/png");
1153
1242
  res.setHeader("Access-Control-Allow-Origin", "*");
1243
+ res.setHeader("Access-Control-Expose-Headers", "x-onlook-render-state");
1244
+ res.setHeader("x-onlook-render-state", renderState);
1154
1245
  res.setHeader("Cache-Control", "no-cache");
1155
1246
  res.end(buffer);
1156
1247
  }).catch((error) => {
@@ -77,6 +77,8 @@ function componentLocPlugin(options = {}) {
77
77
  // src/containment-contract/containment-contract.ts
78
78
  var ONLOOK_CONTAINED_MODULES_GLOBAL = "__ONLOOK_CONTAINED_MODULES__";
79
79
  var ONLOOK_CONTAINED_PROP = "__onlookContained";
80
+ var ONLOOK_RENDER_STATE_ATTR = "data-onlook-render-state";
81
+ var ONLOOK_CONTAINED_CARD_ATTR = "data-onlook-contained-card";
80
82
 
81
83
  // src/env-containment/env-containment.ts
82
84
  var ENV_CONTAINMENT_PLUGIN_NAME = "onlook-env-containment";
@@ -386,11 +388,76 @@ async function getBrowser() {
386
388
  }
387
389
  return browserPromise;
388
390
  }
391
+ function classifyPreviewSnapshot(s) {
392
+ if (s.text.includes("Sorry, something went wrong")) return "errored";
393
+ if (s.text.includes("Couldn't find story")) return "errored";
394
+ if (s.hasContainedCard) return "contained";
395
+ if (s.renderStateAttr === "contained") return "contained";
396
+ if (s.renderStateAttr === "rendered") return "rendered";
397
+ if (s.text.length > 0) return "rendered";
398
+ if (s.hasSizedElement) return "rendered";
399
+ return "pending";
400
+ }
401
+ async function detectRenderState(page, opts = {}) {
402
+ const pollMs = opts.pollMs ?? 250;
403
+ const settleMs = opts.settleMs ?? 4e3;
404
+ const blankStablePolls = opts.blankStablePolls ?? 6;
405
+ const hardCapMs = opts.hardCapMs ?? 2e4;
406
+ const now = opts.now ?? Date.now;
407
+ const start = now();
408
+ let emptyStreak = 0;
409
+ const snapshot = () => page.evaluate(
410
+ ({ stateAttr, cardAttr }) => {
411
+ const root = document.getElementById("storybook-root") ?? document.body;
412
+ const text = (root?.textContent ?? "").trim();
413
+ let hasSizedElement = false;
414
+ if (root) {
415
+ const all = root.querySelectorAll("*");
416
+ for (let i = 0; i < all.length; i++) {
417
+ const rect = all[i]?.getBoundingClientRect();
418
+ if (rect && rect.width > 0 && rect.height > 0) {
419
+ hasSizedElement = true;
420
+ break;
421
+ }
422
+ }
423
+ }
424
+ return {
425
+ text,
426
+ hasContainedCard: Boolean(root?.querySelector(`[${cardAttr}]`)),
427
+ renderStateAttr: root?.getAttribute(stateAttr) ?? null,
428
+ hasSizedElement
429
+ };
430
+ },
431
+ { stateAttr: ONLOOK_RENDER_STATE_ATTR, cardAttr: ONLOOK_CONTAINED_CARD_ATTR }
432
+ );
433
+ while (true) {
434
+ let state;
435
+ let threw = false;
436
+ try {
437
+ state = classifyPreviewSnapshot(await snapshot());
438
+ } catch {
439
+ state = "pending";
440
+ threw = true;
441
+ }
442
+ if (state === "rendered" || state === "contained" || state === "errored") {
443
+ return state;
444
+ }
445
+ const elapsed = now() - start;
446
+ if (threw) {
447
+ emptyStreak = 0;
448
+ } else if (elapsed >= settleMs) {
449
+ emptyStreak += 1;
450
+ if (emptyStreak >= blankStablePolls) return "blank";
451
+ }
452
+ if (elapsed >= hardCapMs) return "timedOut";
453
+ await page.waitForTimeout(pollMs);
454
+ }
455
+ }
389
456
  function getScreenshotPath(storyId, theme) {
390
457
  const storyDir = path7.join(SCREENSHOTS_DIR, storyId);
391
458
  return path7.join(storyDir, `${theme}.png`);
392
459
  }
393
- async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
460
+ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", loadTimeoutMs = 9e4, renderTimeoutMs = 2e4) {
394
461
  const browser = await getBrowser();
395
462
  const context = await browser.newContext({
396
463
  viewport: { width, height },
@@ -399,13 +466,14 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
399
466
  const page = await context.newPage();
400
467
  try {
401
468
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
402
- await page.goto(url, { timeout: timeoutMs });
403
- await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
404
- await page.waitForLoadState("load", { timeout: timeoutMs });
469
+ await page.goto(url, { timeout: loadTimeoutMs });
470
+ await page.waitForLoadState("domcontentloaded", { timeout: loadTimeoutMs });
471
+ await page.waitForLoadState("load", { timeout: loadTimeoutMs });
405
472
  try {
406
473
  await page.waitForLoadState("networkidle", { timeout: 5e3 });
407
474
  } catch {
408
475
  }
476
+ const renderState = await detectRenderState(page, { hardCapMs: renderTimeoutMs });
409
477
  await page.evaluate(() => document.fonts.ready);
410
478
  try {
411
479
  await page.evaluate(async () => {
@@ -467,7 +535,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
467
535
  } else {
468
536
  screenshotBuffer = await page.screenshot({ type: "png" });
469
537
  }
470
- return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
538
+ return { buffer: screenshotBuffer, boundingBox: resultBoundingBox, renderState };
471
539
  } finally {
472
540
  await context.close();
473
541
  }
@@ -788,6 +856,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
788
856
  const page = await context.newPage();
789
857
  const logs = [];
790
858
  const pageErrors = [];
859
+ const failedRequests = [];
791
860
  page.on("console", (msg) => {
792
861
  const level = msg.type();
793
862
  logs.push({
@@ -799,6 +868,28 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
799
868
  page.on("pageerror", (err) => {
800
869
  pageErrors.push(err.message);
801
870
  });
871
+ page.on("response", (res) => {
872
+ const status = res.status();
873
+ if (status >= 400) {
874
+ const req = res.request();
875
+ failedRequests.push({
876
+ url: res.url(),
877
+ status,
878
+ method: req.method(),
879
+ resourceType: req.resourceType(),
880
+ failure: null
881
+ });
882
+ }
883
+ });
884
+ page.on("requestfailed", (req) => {
885
+ failedRequests.push({
886
+ url: req.url(),
887
+ status: null,
888
+ method: req.method(),
889
+ resourceType: req.resourceType(),
890
+ failure: req.failure()?.errorText ?? "request failed"
891
+ });
892
+ });
802
893
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
803
894
  try {
804
895
  await page.goto(url, { timeout: timeoutMs });
@@ -809,7 +900,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
809
900
  } catch {
810
901
  }
811
902
  await new Promise((r) => setTimeout(r, 1e3));
812
- return { logs, pageErrors, url };
903
+ return { logs, pageErrors, failedRequests, url };
813
904
  } finally {
814
905
  await context.close();
815
906
  }
@@ -1141,9 +1232,11 @@ var serveMetadataAndScreenshots = (req, res, next) => {
1141
1232
  res.end(JSON.stringify({ error: 'theme must be "light" or "dark"' }));
1142
1233
  return;
1143
1234
  }
1144
- captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer }) => {
1235
+ captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer, renderState }) => {
1145
1236
  res.setHeader("Content-Type", "image/png");
1146
1237
  res.setHeader("Access-Control-Allow-Origin", "*");
1238
+ res.setHeader("Access-Control-Expose-Headers", "x-onlook-render-state");
1239
+ res.setHeader("x-onlook-render-state", renderState);
1147
1240
  res.setHeader("Cache-Control", "no-cache");
1148
1241
  res.end(buffer);
1149
1242
  }).catch((error) => {
@@ -141,6 +141,77 @@ async function closeBrowser() {
141
141
  console.error("Error closing browser:", error);
142
142
  }
143
143
  }
144
+
145
+ // src/containment-contract/containment-contract.ts
146
+ var ONLOOK_RENDER_STATE_ATTR = "data-onlook-render-state";
147
+ var ONLOOK_CONTAINED_CARD_ATTR = "data-onlook-contained-card";
148
+
149
+ // src/screenshot-service/utils/screenshot/screenshot.ts
150
+ function classifyPreviewSnapshot(s) {
151
+ if (s.text.includes("Sorry, something went wrong")) return "errored";
152
+ if (s.text.includes("Couldn't find story")) return "errored";
153
+ if (s.hasContainedCard) return "contained";
154
+ if (s.renderStateAttr === "contained") return "contained";
155
+ if (s.renderStateAttr === "rendered") return "rendered";
156
+ if (s.text.length > 0) return "rendered";
157
+ if (s.hasSizedElement) return "rendered";
158
+ return "pending";
159
+ }
160
+ async function detectRenderState(page, opts = {}) {
161
+ const pollMs = opts.pollMs ?? 250;
162
+ const settleMs = opts.settleMs ?? 4e3;
163
+ const blankStablePolls = opts.blankStablePolls ?? 6;
164
+ const hardCapMs = opts.hardCapMs ?? 2e4;
165
+ const now = opts.now ?? Date.now;
166
+ const start = now();
167
+ let emptyStreak = 0;
168
+ const snapshot = () => page.evaluate(
169
+ ({ stateAttr, cardAttr }) => {
170
+ const root = document.getElementById("storybook-root") ?? document.body;
171
+ const text = (root?.textContent ?? "").trim();
172
+ let hasSizedElement = false;
173
+ if (root) {
174
+ const all = root.querySelectorAll("*");
175
+ for (let i = 0; i < all.length; i++) {
176
+ const rect = all[i]?.getBoundingClientRect();
177
+ if (rect && rect.width > 0 && rect.height > 0) {
178
+ hasSizedElement = true;
179
+ break;
180
+ }
181
+ }
182
+ }
183
+ return {
184
+ text,
185
+ hasContainedCard: Boolean(root?.querySelector(`[${cardAttr}]`)),
186
+ renderStateAttr: root?.getAttribute(stateAttr) ?? null,
187
+ hasSizedElement
188
+ };
189
+ },
190
+ { stateAttr: ONLOOK_RENDER_STATE_ATTR, cardAttr: ONLOOK_CONTAINED_CARD_ATTR }
191
+ );
192
+ while (true) {
193
+ let state;
194
+ let threw = false;
195
+ try {
196
+ state = classifyPreviewSnapshot(await snapshot());
197
+ } catch {
198
+ state = "pending";
199
+ threw = true;
200
+ }
201
+ if (state === "rendered" || state === "contained" || state === "errored") {
202
+ return state;
203
+ }
204
+ const elapsed = now() - start;
205
+ if (threw) {
206
+ emptyStreak = 0;
207
+ } else if (elapsed >= settleMs) {
208
+ emptyStreak += 1;
209
+ if (emptyStreak >= blankStablePolls) return "blank";
210
+ }
211
+ if (elapsed >= hardCapMs) return "timedOut";
212
+ await page.waitForTimeout(pollMs);
213
+ }
214
+ }
144
215
  function getScreenshotPath(storyId, theme) {
145
216
  const storyDir = path.join(SCREENSHOTS_DIR, storyId);
146
217
  return path.join(storyDir, `${theme}.png`);
@@ -149,7 +220,7 @@ function screenshotExists(storyId, theme) {
149
220
  const screenshotPath = getScreenshotPath(storyId, theme);
150
221
  return fs.existsSync(screenshotPath);
151
222
  }
152
- async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
223
+ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", loadTimeoutMs = 9e4, renderTimeoutMs = 2e4) {
153
224
  const browser = await getBrowser();
154
225
  const context = await browser.newContext({
155
226
  viewport: { width, height },
@@ -158,13 +229,14 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
158
229
  const page = await context.newPage();
159
230
  try {
160
231
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
161
- await page.goto(url, { timeout: timeoutMs });
162
- await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
163
- await page.waitForLoadState("load", { timeout: timeoutMs });
232
+ await page.goto(url, { timeout: loadTimeoutMs });
233
+ await page.waitForLoadState("domcontentloaded", { timeout: loadTimeoutMs });
234
+ await page.waitForLoadState("load", { timeout: loadTimeoutMs });
164
235
  try {
165
236
  await page.waitForLoadState("networkidle", { timeout: 5e3 });
166
237
  } catch {
167
238
  }
239
+ const renderState = await detectRenderState(page, { hardCapMs: renderTimeoutMs });
168
240
  await page.evaluate(() => document.fonts.ready);
169
241
  try {
170
242
  await page.evaluate(async () => {
@@ -226,7 +298,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
226
298
  } else {
227
299
  screenshotBuffer = await page.screenshot({ type: "png" });
228
300
  }
229
- return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
301
+ return { buffer: screenshotBuffer, boundingBox: resultBoundingBox, renderState };
230
302
  } finally {
231
303
  await context.close();
232
304
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlook/storybook-plugin",
3
- "version": "0.4.0-beta.11",
3
+ "version": "0.4.0-beta.12",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "onlook-storybook": "./dist/cli/index.js"