@onlook/storybook-plugin 0.4.0-beta.11 → 0.4.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +119 -29
- package/dist/index.d.ts +14 -0
- package/dist/index.js +98 -7
- package/dist/preset/index.d.ts +5 -1
- package/dist/preset/index.js +220 -18
- package/dist/preview/index.d.ts +46 -2
- package/dist/preview/index.js +31 -9
- package/dist/screenshot-service/index.js +77 -5
- package/package.json +2 -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.d.ts
CHANGED
|
@@ -53,6 +53,20 @@ type OnlookContainedPayload = {
|
|
|
53
53
|
message: string;
|
|
54
54
|
/** Present when the failure is attributed to a contained module (U4). */
|
|
55
55
|
module?: ContainedModuleMarker;
|
|
56
|
+
/**
|
|
57
|
+
* React component stack from `onCaughtError`'s `errorInfo` (ONL-1505,
|
|
58
|
+
* additive). Present only on the `via: 'onCaughtError'` path — the legacy
|
|
59
|
+
* boundary decorator has no component stack to offer.
|
|
60
|
+
*/
|
|
61
|
+
componentStack?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Which catch path emitted this (ONL-1505, additive). `'onCaughtError'` =
|
|
64
|
+
* the React root hook registered via `parameters.react.rootOptions` (catches
|
|
65
|
+
* component render-throws, incl. those caught by Storybook's own
|
|
66
|
+
* renderToCanvas ErrorBoundary); `'decorator'` = the legacy
|
|
67
|
+
* OnlookContainmentBoundary decorator. Absent ⟹ legacy decorator (pre-1505).
|
|
68
|
+
*/
|
|
69
|
+
via?: 'onCaughtError' | 'decorator';
|
|
56
70
|
};
|
|
57
71
|
type OnlookRenderedPayload = {
|
|
58
72
|
storyId: string;
|
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.d.ts
CHANGED
|
@@ -2,9 +2,13 @@ import { O as OnlookPluginOptions } from '../storybook-onlook-plugin-B9Eo_OIq.js
|
|
|
2
2
|
import 'vite';
|
|
3
3
|
|
|
4
4
|
declare function pluginOptionsFromPresetOptions(options: Record<string, unknown> | undefined): OnlookPluginOptions;
|
|
5
|
+
type ChannelLike = {
|
|
6
|
+
on(eventName: string, listener: (...args: unknown[]) => void): unknown;
|
|
7
|
+
};
|
|
8
|
+
declare function experimental_serverChannel(channel: ChannelLike): Promise<ChannelLike>;
|
|
5
9
|
type ViteConfigLike = {
|
|
6
10
|
plugins?: unknown[];
|
|
7
11
|
} & Record<string, unknown>;
|
|
8
12
|
declare function viteFinal(config: ViteConfigLike, options?: Record<string, unknown>): Promise<ViteConfigLike>;
|
|
9
13
|
|
|
10
|
-
export { pluginOptionsFromPresetOptions, viteFinal };
|
|
14
|
+
export { experimental_serverChannel, pluginOptionsFromPresetOptions, viteFinal };
|
package/dist/preset/index.js
CHANGED
|
@@ -13,6 +13,15 @@ import { promisify } from 'util';
|
|
|
13
13
|
import { chromium } from 'playwright';
|
|
14
14
|
|
|
15
15
|
globalThis.require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
// src/containment-contract/containment-contract.ts
|
|
18
|
+
var ONLOOK_CONTAINED_MODULES_GLOBAL = "__ONLOOK_CONTAINED_MODULES__";
|
|
19
|
+
var ONLOOK_CONTAINED_PROP = "__onlookContained";
|
|
20
|
+
var ONLOOK_CONTAINED_EVENT = "onlook/contained";
|
|
21
|
+
var ONLOOK_RENDERED_EVENT = "onlook/rendered";
|
|
22
|
+
var ONLOOK_UNCONTAINED_ERROR_EVENT = "onlook/uncontained-error";
|
|
23
|
+
var ONLOOK_RENDER_STATE_ATTR = "data-onlook-render-state";
|
|
24
|
+
var ONLOOK_CONTAINED_CARD_ATTR = "data-onlook-contained-card";
|
|
16
25
|
function componentLocPlugin(options = {}) {
|
|
17
26
|
const include = options.include ?? /\.(jsx|tsx)$/;
|
|
18
27
|
const traverse = traverseModule.default ?? traverseModule;
|
|
@@ -73,12 +82,6 @@ function componentLocPlugin(options = {}) {
|
|
|
73
82
|
}
|
|
74
83
|
};
|
|
75
84
|
}
|
|
76
|
-
|
|
77
|
-
// src/containment-contract/containment-contract.ts
|
|
78
|
-
var ONLOOK_CONTAINED_MODULES_GLOBAL = "__ONLOOK_CONTAINED_MODULES__";
|
|
79
|
-
var ONLOOK_CONTAINED_PROP = "__onlookContained";
|
|
80
|
-
|
|
81
|
-
// src/env-containment/env-containment.ts
|
|
82
85
|
var ENV_CONTAINMENT_PLUGIN_NAME = "onlook-env-containment";
|
|
83
86
|
var ENV_CONTAINMENT_VIRTUAL_PREFIX = "\0onlook-env-containment:";
|
|
84
87
|
var VALID_IDENT_RE = /^[A-Za-z_$][\w$]*$/;
|
|
@@ -386,11 +389,76 @@ async function getBrowser() {
|
|
|
386
389
|
}
|
|
387
390
|
return browserPromise;
|
|
388
391
|
}
|
|
392
|
+
function classifyPreviewSnapshot(s) {
|
|
393
|
+
if (s.text.includes("Sorry, something went wrong")) return "errored";
|
|
394
|
+
if (s.text.includes("Couldn't find story")) return "errored";
|
|
395
|
+
if (s.hasContainedCard) return "contained";
|
|
396
|
+
if (s.renderStateAttr === "contained") return "contained";
|
|
397
|
+
if (s.renderStateAttr === "rendered") return "rendered";
|
|
398
|
+
if (s.text.length > 0) return "rendered";
|
|
399
|
+
if (s.hasSizedElement) return "rendered";
|
|
400
|
+
return "pending";
|
|
401
|
+
}
|
|
402
|
+
async function detectRenderState(page, opts = {}) {
|
|
403
|
+
const pollMs = opts.pollMs ?? 250;
|
|
404
|
+
const settleMs = opts.settleMs ?? 4e3;
|
|
405
|
+
const blankStablePolls = opts.blankStablePolls ?? 6;
|
|
406
|
+
const hardCapMs = opts.hardCapMs ?? 2e4;
|
|
407
|
+
const now = opts.now ?? Date.now;
|
|
408
|
+
const start = now();
|
|
409
|
+
let emptyStreak = 0;
|
|
410
|
+
const snapshot = () => page.evaluate(
|
|
411
|
+
({ stateAttr, cardAttr }) => {
|
|
412
|
+
const root = document.getElementById("storybook-root") ?? document.body;
|
|
413
|
+
const text = (root?.textContent ?? "").trim();
|
|
414
|
+
let hasSizedElement = false;
|
|
415
|
+
if (root) {
|
|
416
|
+
const all = root.querySelectorAll("*");
|
|
417
|
+
for (let i = 0; i < all.length; i++) {
|
|
418
|
+
const rect = all[i]?.getBoundingClientRect();
|
|
419
|
+
if (rect && rect.width > 0 && rect.height > 0) {
|
|
420
|
+
hasSizedElement = true;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
text,
|
|
427
|
+
hasContainedCard: Boolean(root?.querySelector(`[${cardAttr}]`)),
|
|
428
|
+
renderStateAttr: root?.getAttribute(stateAttr) ?? null,
|
|
429
|
+
hasSizedElement
|
|
430
|
+
};
|
|
431
|
+
},
|
|
432
|
+
{ stateAttr: ONLOOK_RENDER_STATE_ATTR, cardAttr: ONLOOK_CONTAINED_CARD_ATTR }
|
|
433
|
+
);
|
|
434
|
+
while (true) {
|
|
435
|
+
let state;
|
|
436
|
+
let threw = false;
|
|
437
|
+
try {
|
|
438
|
+
state = classifyPreviewSnapshot(await snapshot());
|
|
439
|
+
} catch {
|
|
440
|
+
state = "pending";
|
|
441
|
+
threw = true;
|
|
442
|
+
}
|
|
443
|
+
if (state === "rendered" || state === "contained" || state === "errored") {
|
|
444
|
+
return state;
|
|
445
|
+
}
|
|
446
|
+
const elapsed = now() - start;
|
|
447
|
+
if (threw) {
|
|
448
|
+
emptyStreak = 0;
|
|
449
|
+
} else if (elapsed >= settleMs) {
|
|
450
|
+
emptyStreak += 1;
|
|
451
|
+
if (emptyStreak >= blankStablePolls) return "blank";
|
|
452
|
+
}
|
|
453
|
+
if (elapsed >= hardCapMs) return "timedOut";
|
|
454
|
+
await page.waitForTimeout(pollMs);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
389
457
|
function getScreenshotPath(storyId, theme) {
|
|
390
458
|
const storyDir = path7.join(SCREENSHOTS_DIR, storyId);
|
|
391
459
|
return path7.join(storyDir, `${theme}.png`);
|
|
392
460
|
}
|
|
393
|
-
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006",
|
|
461
|
+
async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", loadTimeoutMs = 9e4, renderTimeoutMs = 2e4) {
|
|
394
462
|
const browser = await getBrowser();
|
|
395
463
|
const context = await browser.newContext({
|
|
396
464
|
viewport: { width, height },
|
|
@@ -399,13 +467,14 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
|
|
|
399
467
|
const page = await context.newPage();
|
|
400
468
|
try {
|
|
401
469
|
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:
|
|
470
|
+
await page.goto(url, { timeout: loadTimeoutMs });
|
|
471
|
+
await page.waitForLoadState("domcontentloaded", { timeout: loadTimeoutMs });
|
|
472
|
+
await page.waitForLoadState("load", { timeout: loadTimeoutMs });
|
|
405
473
|
try {
|
|
406
474
|
await page.waitForLoadState("networkidle", { timeout: 5e3 });
|
|
407
475
|
} catch {
|
|
408
476
|
}
|
|
477
|
+
const renderState = await detectRenderState(page, { hardCapMs: renderTimeoutMs });
|
|
409
478
|
await page.evaluate(() => document.fonts.ready);
|
|
410
479
|
try {
|
|
411
480
|
await page.evaluate(async () => {
|
|
@@ -467,7 +536,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
|
|
|
467
536
|
} else {
|
|
468
537
|
screenshotBuffer = await page.screenshot({ type: "png" });
|
|
469
538
|
}
|
|
470
|
-
return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
|
|
539
|
+
return { buffer: screenshotBuffer, boundingBox: resultBoundingBox, renderState };
|
|
471
540
|
} finally {
|
|
472
541
|
await context.close();
|
|
473
542
|
}
|
|
@@ -788,6 +857,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
|
|
|
788
857
|
const page = await context.newPage();
|
|
789
858
|
const logs = [];
|
|
790
859
|
const pageErrors = [];
|
|
860
|
+
const failedRequests = [];
|
|
791
861
|
page.on("console", (msg) => {
|
|
792
862
|
const level = msg.type();
|
|
793
863
|
logs.push({
|
|
@@ -799,6 +869,28 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
|
|
|
799
869
|
page.on("pageerror", (err) => {
|
|
800
870
|
pageErrors.push(err.message);
|
|
801
871
|
});
|
|
872
|
+
page.on("response", (res) => {
|
|
873
|
+
const status = res.status();
|
|
874
|
+
if (status >= 400) {
|
|
875
|
+
const req = res.request();
|
|
876
|
+
failedRequests.push({
|
|
877
|
+
url: res.url(),
|
|
878
|
+
status,
|
|
879
|
+
method: req.method(),
|
|
880
|
+
resourceType: req.resourceType(),
|
|
881
|
+
failure: null
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
page.on("requestfailed", (req) => {
|
|
886
|
+
failedRequests.push({
|
|
887
|
+
url: req.url(),
|
|
888
|
+
status: null,
|
|
889
|
+
method: req.method(),
|
|
890
|
+
resourceType: req.resourceType(),
|
|
891
|
+
failure: req.failure()?.errorText ?? "request failed"
|
|
892
|
+
});
|
|
893
|
+
});
|
|
802
894
|
const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
|
|
803
895
|
try {
|
|
804
896
|
await page.goto(url, { timeout: timeoutMs });
|
|
@@ -809,7 +901,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
|
|
|
809
901
|
} catch {
|
|
810
902
|
}
|
|
811
903
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
812
|
-
return { logs, pageErrors, url };
|
|
904
|
+
return { logs, pageErrors, failedRequests, url };
|
|
813
905
|
} finally {
|
|
814
906
|
await context.close();
|
|
815
907
|
}
|
|
@@ -1141,9 +1233,11 @@ var serveMetadataAndScreenshots = (req, res, next) => {
|
|
|
1141
1233
|
res.end(JSON.stringify({ error: 'theme must be "light" or "dark"' }));
|
|
1142
1234
|
return;
|
|
1143
1235
|
}
|
|
1144
|
-
captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer }) => {
|
|
1236
|
+
captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer, renderState }) => {
|
|
1145
1237
|
res.setHeader("Content-Type", "image/png");
|
|
1146
1238
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1239
|
+
res.setHeader("Access-Control-Expose-Headers", "x-onlook-render-state");
|
|
1240
|
+
res.setHeader("x-onlook-render-state", renderState);
|
|
1147
1241
|
res.setHeader("Cache-Control", "no-cache");
|
|
1148
1242
|
res.end(buffer);
|
|
1149
1243
|
}).catch((error) => {
|
|
@@ -1338,12 +1432,12 @@ function storybookOnlookPlugin(options = {}) {
|
|
|
1338
1432
|
"manifest.json"
|
|
1339
1433
|
);
|
|
1340
1434
|
refreshManifest(cachedManifestPath);
|
|
1341
|
-
let
|
|
1435
|
+
let pending3;
|
|
1342
1436
|
const scheduleRefresh = (file) => {
|
|
1343
1437
|
if (file !== cachedManifestPath) return;
|
|
1344
|
-
if (
|
|
1345
|
-
|
|
1346
|
-
|
|
1438
|
+
if (pending3) clearTimeout(pending3);
|
|
1439
|
+
pending3 = setTimeout(() => {
|
|
1440
|
+
pending3 = void 0;
|
|
1347
1441
|
refreshManifest(cachedManifestPath);
|
|
1348
1442
|
}, 100);
|
|
1349
1443
|
};
|
|
@@ -1428,6 +1522,68 @@ function storybookOnlookPlugin(options = {}) {
|
|
|
1428
1522
|
);
|
|
1429
1523
|
}
|
|
1430
1524
|
|
|
1525
|
+
// src/utils/notifyStoryError/notifyStoryError.ts
|
|
1526
|
+
var DEBOUNCE_MS3 = 300;
|
|
1527
|
+
var REQUEST_TIMEOUT_MS2 = 5e3;
|
|
1528
|
+
var pending2 = null;
|
|
1529
|
+
var inFlight2 = false;
|
|
1530
|
+
var trailing = false;
|
|
1531
|
+
var latest = null;
|
|
1532
|
+
function notifyStoryError(payload) {
|
|
1533
|
+
enqueue({ type: "STORY_ERROR", ...payload });
|
|
1534
|
+
}
|
|
1535
|
+
function notifyStoryResolved(storyId) {
|
|
1536
|
+
enqueue({ type: "STORY_RESOLVED", storyId });
|
|
1537
|
+
}
|
|
1538
|
+
function enqueue(event) {
|
|
1539
|
+
const proxyToken = process.env.ONBOOK_PROXY_TOKEN;
|
|
1540
|
+
const broadcastUrl = process.env.ONBOOK_BROADCAST_URL;
|
|
1541
|
+
if (!proxyToken || !broadcastUrl) return;
|
|
1542
|
+
latest = event;
|
|
1543
|
+
if (pending2) clearTimeout(pending2);
|
|
1544
|
+
pending2 = setTimeout(() => {
|
|
1545
|
+
pending2 = null;
|
|
1546
|
+
if (inFlight2) {
|
|
1547
|
+
trailing = true;
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
void fire2(proxyToken, broadcastUrl);
|
|
1551
|
+
}, DEBOUNCE_MS3);
|
|
1552
|
+
}
|
|
1553
|
+
async function fire2(proxyToken, broadcastUrl) {
|
|
1554
|
+
const event = latest;
|
|
1555
|
+
if (!event) return;
|
|
1556
|
+
inFlight2 = true;
|
|
1557
|
+
try {
|
|
1558
|
+
const res = await fetch(broadcastUrl, {
|
|
1559
|
+
method: "POST",
|
|
1560
|
+
headers: {
|
|
1561
|
+
Authorization: `Bearer ${proxyToken}`,
|
|
1562
|
+
"Content-Type": "application/json"
|
|
1563
|
+
},
|
|
1564
|
+
body: JSON.stringify({ event }),
|
|
1565
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
|
|
1566
|
+
});
|
|
1567
|
+
if (!res.ok) {
|
|
1568
|
+
console.warn("[STORYBOOK_PLUGIN] story-event broadcast non-2xx", {
|
|
1569
|
+
status: res.status,
|
|
1570
|
+
statusText: res.statusText
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
console.warn(
|
|
1575
|
+
"[STORYBOOK_PLUGIN] story-event broadcast failed",
|
|
1576
|
+
err instanceof Error ? err.message : String(err)
|
|
1577
|
+
);
|
|
1578
|
+
} finally {
|
|
1579
|
+
inFlight2 = false;
|
|
1580
|
+
if (trailing) {
|
|
1581
|
+
trailing = false;
|
|
1582
|
+
void fire2(proxyToken, broadcastUrl);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1431
1587
|
// src/preset/preset.ts
|
|
1432
1588
|
var OPTION_KEY_MAP = {
|
|
1433
1589
|
port: true,
|
|
@@ -1449,10 +1605,56 @@ function pluginOptionsFromPresetOptions(options) {
|
|
|
1449
1605
|
}
|
|
1450
1606
|
return picked;
|
|
1451
1607
|
}
|
|
1608
|
+
var erroredStories = /* @__PURE__ */ new Map();
|
|
1609
|
+
var RENDER_RESOLVE_SUPPRESS_MS = 1500;
|
|
1610
|
+
async function experimental_serverChannel(channel) {
|
|
1611
|
+
try {
|
|
1612
|
+
channel.on(ONLOOK_CONTAINED_EVENT, (...args) => {
|
|
1613
|
+
const payload = args[0];
|
|
1614
|
+
if (!payload) return;
|
|
1615
|
+
console.error(`[ONBOOK_STORY_ERROR] contained story=${payload.storyId || "?"}`);
|
|
1616
|
+
if (payload.storyId) erroredStories.set(payload.storyId, Date.now());
|
|
1617
|
+
notifyStoryError({
|
|
1618
|
+
storyId: payload.storyId ?? "",
|
|
1619
|
+
message: payload.message ?? "Unknown story error",
|
|
1620
|
+
source: "contained",
|
|
1621
|
+
...payload.componentStack ? { componentStack: payload.componentStack } : {}
|
|
1622
|
+
});
|
|
1623
|
+
});
|
|
1624
|
+
channel.on(ONLOOK_UNCONTAINED_ERROR_EVENT, (...args) => {
|
|
1625
|
+
const payload = args[0];
|
|
1626
|
+
if (!payload) return;
|
|
1627
|
+
console.error(`[ONBOOK_STORY_ERROR] uncontained story=${payload.storyId || "?"}`);
|
|
1628
|
+
if (payload.storyId) erroredStories.set(payload.storyId, Date.now());
|
|
1629
|
+
notifyStoryError({
|
|
1630
|
+
storyId: payload.storyId ?? "",
|
|
1631
|
+
message: payload.message ?? "Unknown story error",
|
|
1632
|
+
source: "uncontained"
|
|
1633
|
+
});
|
|
1634
|
+
});
|
|
1635
|
+
channel.on(ONLOOK_RENDERED_EVENT, (...args) => {
|
|
1636
|
+
const payload = args[0];
|
|
1637
|
+
if (!payload?.storyId) return;
|
|
1638
|
+
const erroredAt = erroredStories.get(payload.storyId);
|
|
1639
|
+
if (erroredAt === void 0) return;
|
|
1640
|
+
if (Date.now() - erroredAt < RENDER_RESOLVE_SUPPRESS_MS) return;
|
|
1641
|
+
erroredStories.delete(payload.storyId);
|
|
1642
|
+
console.error(`[ONBOOK_STORY_ERROR] resolved story=${payload.storyId}`);
|
|
1643
|
+
notifyStoryResolved(payload.storyId);
|
|
1644
|
+
});
|
|
1645
|
+
console.error("[ONBOOK_STORY_ERROR] server-channel error + recovery tap attached");
|
|
1646
|
+
} catch (err) {
|
|
1647
|
+
console.error(
|
|
1648
|
+
"[ONBOOK_STORY_ERROR] failed to attach server-channel tap",
|
|
1649
|
+
err instanceof Error ? err.message : String(err)
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
return channel;
|
|
1653
|
+
}
|
|
1452
1654
|
async function viteFinal(config, options = {}) {
|
|
1453
1655
|
const plugins = storybookOnlookPlugin(pluginOptionsFromPresetOptions(options));
|
|
1454
1656
|
if (plugins.length === 0) return config;
|
|
1455
1657
|
return { ...config, plugins: [...config.plugins ?? [], ...plugins] };
|
|
1456
1658
|
}
|
|
1457
1659
|
|
|
1458
|
-
export { pluginOptionsFromPresetOptions, viteFinal };
|
|
1660
|
+
export { experimental_serverChannel, pluginOptionsFromPresetOptions, viteFinal };
|
package/dist/preview/index.d.ts
CHANGED
|
@@ -44,6 +44,20 @@ type OnlookContainedPayload = {
|
|
|
44
44
|
message: string;
|
|
45
45
|
/** Present when the failure is attributed to a contained module (U4). */
|
|
46
46
|
module?: ContainedModuleMarker;
|
|
47
|
+
/**
|
|
48
|
+
* React component stack from `onCaughtError`'s `errorInfo` (ONL-1505,
|
|
49
|
+
* additive). Present only on the `via: 'onCaughtError'` path — the legacy
|
|
50
|
+
* boundary decorator has no component stack to offer.
|
|
51
|
+
*/
|
|
52
|
+
componentStack?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Which catch path emitted this (ONL-1505, additive). `'onCaughtError'` =
|
|
55
|
+
* the React root hook registered via `parameters.react.rootOptions` (catches
|
|
56
|
+
* component render-throws, incl. those caught by Storybook's own
|
|
57
|
+
* renderToCanvas ErrorBoundary); `'decorator'` = the legacy
|
|
58
|
+
* OnlookContainmentBoundary decorator. Absent ⟹ legacy decorator (pre-1505).
|
|
59
|
+
*/
|
|
60
|
+
via?: 'onCaughtError' | 'decorator';
|
|
47
61
|
};
|
|
48
62
|
type OnlookRenderedPayload = {
|
|
49
63
|
storyId: string;
|
|
@@ -92,7 +106,7 @@ declare class OnlookContainmentBoundary extends React.Component<BoundaryProps, B
|
|
|
92
106
|
private renderedSignalSent;
|
|
93
107
|
constructor(props: BoundaryProps);
|
|
94
108
|
static getDerivedStateFromError(error: unknown): BoundaryState;
|
|
95
|
-
componentDidCatch(
|
|
109
|
+
componentDidCatch(): void;
|
|
96
110
|
componentDidMount(): void;
|
|
97
111
|
componentDidUpdate(): void;
|
|
98
112
|
componentWillUnmount(): void;
|
|
@@ -134,6 +148,27 @@ declare function getPreviewChannel(): ChannelLike | null;
|
|
|
134
148
|
* stay byte-stable.
|
|
135
149
|
*/
|
|
136
150
|
declare function isSnapshotEnvironment(): boolean;
|
|
151
|
+
/** Trim a React component stack to a payload-sized summary. */
|
|
152
|
+
declare function summarizeComponentStack(stack: unknown, maxLength?: number): string | undefined;
|
|
153
|
+
/**
|
|
154
|
+
* React root `onCaughtError` handler (ONL-1505). Registered via the preview
|
|
155
|
+
* annotations' `parameters.react.rootOptions.onCaughtError`, which
|
|
156
|
+
* `@storybook/react`'s `renderToCanvas` threads verbatim into `createRoot`.
|
|
157
|
+
*
|
|
158
|
+
* React 19 calls this for ANY error caught by an error boundary in the story
|
|
159
|
+
* tree — crucially including Storybook's own `renderToCanvas` ErrorBoundary,
|
|
160
|
+
* which is what catches a component render-throw. This is the ONLY node-
|
|
161
|
+
* reachable signal for render-crashes: `window.onerror` fires only for
|
|
162
|
+
* UNCAUGHT errors, and the channel `storyThrewException` / `storyErrored`
|
|
163
|
+
* events don't fire for boundary-caught render-throws. We re-emit as the
|
|
164
|
+
* existing `onlook/contained` event (tagged `via: 'onCaughtError'`) so the
|
|
165
|
+
* node-side server-channel tap needs no change.
|
|
166
|
+
*
|
|
167
|
+
* Registering `onCaughtError` replaces React's default (which logs caught
|
|
168
|
+
* errors to `console.error`), so we re-log for dev parity — keeping the
|
|
169
|
+
* iframe console behavior unchanged and giving the live tap a second marker.
|
|
170
|
+
*/
|
|
171
|
+
declare function handleRootCaughtError(error: unknown, componentStack?: unknown): void;
|
|
137
172
|
/**
|
|
138
173
|
* Attribute an error to a contained module (U4's env-containment stand-ins).
|
|
139
174
|
* Primary: the marker the stand-in attaches at `error.__onlookContained`.
|
|
@@ -152,5 +187,14 @@ declare function onlookBeforeAll(): void;
|
|
|
152
187
|
|
|
153
188
|
declare const decorators: (typeof onlookContainmentDecorator)[];
|
|
154
189
|
declare const beforeAll: typeof onlookBeforeAll;
|
|
190
|
+
declare const parameters: {
|
|
191
|
+
react: {
|
|
192
|
+
rootOptions: {
|
|
193
|
+
onCaughtError: (error: unknown, errorInfo?: {
|
|
194
|
+
componentStack?: string;
|
|
195
|
+
}) => void;
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
};
|
|
155
199
|
|
|
156
|
-
export { ContainmentCard, ONLOOK_CONTAINED_CARD_ATTR, ONLOOK_CONTAINED_EVENT, ONLOOK_CONTAINMENT_CONTRACT_GLOBAL, ONLOOK_CONTAINMENT_CONTRACT_VERSION, ONLOOK_RENDERED_EVENT, ONLOOK_RENDER_STATE_ATTR, ONLOOK_RENDER_STORY_ATTR, ONLOOK_UNCONTAINED_ERROR_EVENT, type OnlookContainedPayload, OnlookContainmentBoundary, type OnlookContainmentContract, type OnlookRenderState, type OnlookRenderedPayload, type OnlookUncontainedErrorPayload, attributeErrorToModule, beforeAll, decorators, getPreviewChannel, installUncontainedErrorRelay, isSnapshotEnvironment, onlookBeforeAll, onlookContainmentDecorator };
|
|
200
|
+
export { ContainmentCard, ONLOOK_CONTAINED_CARD_ATTR, ONLOOK_CONTAINED_EVENT, ONLOOK_CONTAINMENT_CONTRACT_GLOBAL, ONLOOK_CONTAINMENT_CONTRACT_VERSION, ONLOOK_RENDERED_EVENT, ONLOOK_RENDER_STATE_ATTR, ONLOOK_RENDER_STORY_ATTR, ONLOOK_UNCONTAINED_ERROR_EVENT, type OnlookContainedPayload, OnlookContainmentBoundary, type OnlookContainmentContract, type OnlookRenderState, type OnlookRenderedPayload, type OnlookUncontainedErrorPayload, attributeErrorToModule, beforeAll, decorators, getPreviewChannel, handleRootCaughtError, installUncontainedErrorRelay, isSnapshotEnvironment, onlookBeforeAll, onlookContainmentDecorator, parameters, summarizeComponentStack };
|
package/dist/preview/index.js
CHANGED
|
@@ -85,6 +85,28 @@ function emitRendered(storyId) {
|
|
|
85
85
|
contractVersion: ONLOOK_CONTAINMENT_CONTRACT_VERSION
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
|
+
function summarizeComponentStack(stack, maxLength = 2e3) {
|
|
89
|
+
if (typeof stack !== "string") return void 0;
|
|
90
|
+
const trimmed = stack.trim();
|
|
91
|
+
if (!trimmed) return void 0;
|
|
92
|
+
return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength - 1)}\u2026` : trimmed;
|
|
93
|
+
}
|
|
94
|
+
function handleRootCaughtError(error, componentStack) {
|
|
95
|
+
try {
|
|
96
|
+
console.error(error);
|
|
97
|
+
} catch {
|
|
98
|
+
}
|
|
99
|
+
const storyId = currentStoryId();
|
|
100
|
+
const stack = summarizeComponentStack(componentStack);
|
|
101
|
+
const module = attributeErrorToModule(error);
|
|
102
|
+
emitContained({
|
|
103
|
+
storyId: storyId ?? "",
|
|
104
|
+
contractVersion: ONLOOK_CONTAINMENT_CONTRACT_VERSION,
|
|
105
|
+
message: summarizeError(error),
|
|
106
|
+
...module ? { module } : {},
|
|
107
|
+
...stack ? { componentStack: stack } : {}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
88
110
|
function summarizeError(error, maxLength = 400) {
|
|
89
111
|
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : String(error);
|
|
90
112
|
const oneline = raw.trim();
|
|
@@ -242,15 +264,8 @@ var OnlookContainmentBoundary = class extends React.Component {
|
|
|
242
264
|
static getDerivedStateFromError(error) {
|
|
243
265
|
return { error, hasError: true };
|
|
244
266
|
}
|
|
245
|
-
componentDidCatch(
|
|
246
|
-
const module = attributeErrorToModule(error);
|
|
267
|
+
componentDidCatch() {
|
|
247
268
|
setRenderState("contained", this.props.storyId);
|
|
248
|
-
emitContained({
|
|
249
|
-
storyId: this.props.storyId,
|
|
250
|
-
contractVersion: ONLOOK_CONTAINMENT_CONTRACT_VERSION,
|
|
251
|
-
message: summarizeError(error),
|
|
252
|
-
...module ? { module } : {}
|
|
253
|
-
});
|
|
254
269
|
}
|
|
255
270
|
componentDidMount() {
|
|
256
271
|
exposeContract();
|
|
@@ -298,5 +313,12 @@ function onlookContainmentDecorator(Story, context) {
|
|
|
298
313
|
// src/preview/index.ts
|
|
299
314
|
var decorators = [onlookContainmentDecorator];
|
|
300
315
|
var beforeAll = onlookBeforeAll;
|
|
316
|
+
var parameters = {
|
|
317
|
+
react: {
|
|
318
|
+
rootOptions: {
|
|
319
|
+
onCaughtError: (error, errorInfo) => handleRootCaughtError(error, errorInfo?.componentStack)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
};
|
|
301
323
|
|
|
302
|
-
export { ContainmentCard, ONLOOK_CONTAINED_CARD_ATTR, ONLOOK_CONTAINED_EVENT, ONLOOK_CONTAINMENT_CONTRACT_GLOBAL, ONLOOK_CONTAINMENT_CONTRACT_VERSION, ONLOOK_RENDERED_EVENT, ONLOOK_RENDER_STATE_ATTR, ONLOOK_RENDER_STORY_ATTR, ONLOOK_UNCONTAINED_ERROR_EVENT, OnlookContainmentBoundary, attributeErrorToModule, beforeAll, decorators, getPreviewChannel, installUncontainedErrorRelay, isSnapshotEnvironment, onlookBeforeAll, onlookContainmentDecorator };
|
|
324
|
+
export { ContainmentCard, ONLOOK_CONTAINED_CARD_ATTR, ONLOOK_CONTAINED_EVENT, ONLOOK_CONTAINMENT_CONTRACT_GLOBAL, ONLOOK_CONTAINMENT_CONTRACT_VERSION, ONLOOK_RENDERED_EVENT, ONLOOK_RENDER_STATE_ATTR, ONLOOK_RENDER_STORY_ATTR, ONLOOK_UNCONTAINED_ERROR_EVENT, OnlookContainmentBoundary, attributeErrorToModule, beforeAll, decorators, getPreviewChannel, handleRootCaughtError, installUncontainedErrorRelay, isSnapshotEnvironment, onlookBeforeAll, onlookContainmentDecorator, parameters, summarizeComponentStack };
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlook/storybook-plugin",
|
|
3
|
-
"version": "0.4.0-beta.
|
|
3
|
+
"version": "0.4.0-beta.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"onlook-storybook": "./dist/cli/index.js"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./package.json": "./package.json",
|
|
13
14
|
"./preset": {
|
|
14
15
|
"types": "./dist/preset/index.d.ts",
|
|
15
16
|
"default": "./dist/preset/index.js"
|