@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 +119 -29
- package/dist/index.js +98 -7
- package/dist/preset/index.js +100 -7
- package/dist/screenshot-service/index.js +77 -5
- package/package.json +1 -1
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",
|
|
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:
|
|
164
|
-
await page.waitForLoadState("domcontentloaded", { timeout:
|
|
165
|
-
await page.waitForLoadState("load", { timeout:
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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,
|
|
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",
|
|
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:
|
|
526
|
-
await page.waitForLoadState("domcontentloaded", { timeout:
|
|
527
|
-
await page.waitForLoadState("load", { timeout:
|
|
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) => {
|
package/dist/preset/index.js
CHANGED
|
@@ -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",
|
|
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:
|
|
403
|
-
await page.waitForLoadState("domcontentloaded", { timeout:
|
|
404
|
-
await page.waitForLoadState("load", { timeout:
|
|
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",
|
|
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:
|
|
162
|
-
await page.waitForLoadState("domcontentloaded", { timeout:
|
|
163
|
-
await page.waitForLoadState("load", { timeout:
|
|
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
|
}
|