@storyshelf/runner-playwright 0.1.0
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/README.md +25 -0
- package/dist/capture-runner.d.mts +9 -0
- package/dist/capture-runner.mjs +116 -0
- package/dist/capture-runner.mjs.map +1 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +5 -0
- package/dist/static-server.mjs +83 -0
- package/dist/static-server.mjs.map +1 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# @storyshelf/runner-playwright
|
|
2
|
+
|
|
3
|
+
A **pure** `CaptureRunner` implementation for StoryShelf: renders screenshots of Storybook stories **in-process** with Playwright. It serves an already-extracted Storybook directory over a local HTTP server, launches Chromium, renders every story at each configured viewport, and returns the screenshot buffers. It performs **no** database, storage, or build-state management — that is core's capture orchestrator's job (ADR 0015).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
nub add @storyshelf/runner-playwright
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createPlaywrightCaptureRunner } from "@storyshelf/runner-playwright";
|
|
15
|
+
|
|
16
|
+
const capture = createPlaywrightCaptureRunner();
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Supply it to `createShelfRouter({ capture, config: { scratchDir } })` — the router builds the orchestrator that loads the build, extracts the uploaded archive into `scratchDir`, discovers stories, calls `capture.render(...)`, and persists. `server` is the assembly point that hard-wires this renderer today (`storyshelf-server serve`, passing `--data-dir` as `scratchDir`). A future alternative (e.g. a remote runner offloading capture to a worker fleet) implements the same pure `CaptureRunner` interface from `@storyshelf/core/adapters/capture-runner` as its own package and is swapped in at the `serve` assembly site — the router, orchestrator, and pipeline never change.
|
|
20
|
+
|
|
21
|
+
## How it fits
|
|
22
|
+
|
|
23
|
+
Core's capture logic is browser-agnostic: the **orchestrator** (`@storyshelf/core/capture`) owns loading, extraction, discovery, and persistence, and delegates only the actual screenshotting to this package's `render(input) => RenderResult`. This package supplies the pure renderer and the ephemeral Storybook static server it needs. It is the only place that depends on `playwright`.
|
|
24
|
+
|
|
25
|
+
See `docs/architecture.md` for the capture workflow and `docs/testing.md` for the gated browser integration suite.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import __tsdown_shims_path from 'node:path';
|
|
2
|
+
import __tsdown_shims_url from 'node:url';
|
|
3
|
+
import { CaptureRunner } from "@storyshelf/core";
|
|
4
|
+
import "pino";
|
|
5
|
+
//#region src/capture-runner.d.ts
|
|
6
|
+
declare function createPlaywrightCaptureRunner(): CaptureRunner;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { createPlaywrightCaptureRunner };
|
|
9
|
+
//# sourceMappingURL=capture-runner.d.mts.map
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { createStaticServer } from "./static-server.mjs";
|
|
5
|
+
import { chromium } from "playwright";
|
|
6
|
+
import { StorybookAdapter } from "@storyshelf/core";
|
|
7
|
+
//#region src/capture-runner.ts
|
|
8
|
+
const activeRuns = /* @__PURE__ */ new Map();
|
|
9
|
+
function createPlaywrightCaptureRunner() {
|
|
10
|
+
return {
|
|
11
|
+
metadata: {
|
|
12
|
+
name: "Playwright",
|
|
13
|
+
version: "0.1.0",
|
|
14
|
+
description: "Playwright capture runner",
|
|
15
|
+
kind: "playwright"
|
|
16
|
+
},
|
|
17
|
+
async render(input) {
|
|
18
|
+
const active = {
|
|
19
|
+
cancelled: false,
|
|
20
|
+
browser: null
|
|
21
|
+
};
|
|
22
|
+
activeRuns.set(input.buildId, active);
|
|
23
|
+
try {
|
|
24
|
+
return await renderAll(input, active);
|
|
25
|
+
} finally {
|
|
26
|
+
activeRuns.delete(input.buildId);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
async cancel(buildId) {
|
|
30
|
+
const active = activeRuns.get(buildId);
|
|
31
|
+
if (!active) return;
|
|
32
|
+
active.cancelled = true;
|
|
33
|
+
await closeBrowser(active.browser);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function closeBrowser(browser) {
|
|
38
|
+
if (!browser) return;
|
|
39
|
+
try {
|
|
40
|
+
await browser.close();
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
async function renderAll(input, active) {
|
|
44
|
+
const server = await createStaticServer(input.storybookDir);
|
|
45
|
+
const browser = await chromium.launch();
|
|
46
|
+
active.browser = browser;
|
|
47
|
+
const ctx = {
|
|
48
|
+
browser,
|
|
49
|
+
adapter: new StorybookAdapter(),
|
|
50
|
+
baseUrl: server.url
|
|
51
|
+
};
|
|
52
|
+
const captures = [];
|
|
53
|
+
const failures = [];
|
|
54
|
+
try {
|
|
55
|
+
const tasks = input.viewports.flatMap((viewport) => input.stories.map(async (story) => {
|
|
56
|
+
if (active.cancelled) throw new Error("Capture cancelled");
|
|
57
|
+
try {
|
|
58
|
+
const screenshot = await captureScreenshot(ctx, story, viewport);
|
|
59
|
+
captures.push({
|
|
60
|
+
story,
|
|
61
|
+
viewportName: viewport.name,
|
|
62
|
+
screenshot
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
failures.push({
|
|
66
|
+
storyId: story.id,
|
|
67
|
+
viewportName: viewport.name,
|
|
68
|
+
error: messageOf(error)
|
|
69
|
+
});
|
|
70
|
+
input.logger?.error({
|
|
71
|
+
storyId: story.id,
|
|
72
|
+
viewport: viewport.name,
|
|
73
|
+
err: error
|
|
74
|
+
}, "render failed for story");
|
|
75
|
+
}
|
|
76
|
+
}));
|
|
77
|
+
await Promise.all(tasks);
|
|
78
|
+
return {
|
|
79
|
+
captures,
|
|
80
|
+
failures
|
|
81
|
+
};
|
|
82
|
+
} finally {
|
|
83
|
+
active.browser = null;
|
|
84
|
+
await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function messageOf(error) {
|
|
88
|
+
return error instanceof Error ? error.message : String(error);
|
|
89
|
+
}
|
|
90
|
+
async function safeCloseBrowser(browser) {
|
|
91
|
+
try {
|
|
92
|
+
await browser.close();
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
async function safeCloseServer(server) {
|
|
96
|
+
try {
|
|
97
|
+
await server.close();
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
async function captureScreenshot(ctx, story, viewport) {
|
|
101
|
+
const page = await ctx.browser.newPage({ viewport: {
|
|
102
|
+
width: viewport.width,
|
|
103
|
+
height: viewport.height
|
|
104
|
+
} });
|
|
105
|
+
try {
|
|
106
|
+
await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id), { waitUntil: "networkidle" });
|
|
107
|
+
if (ctx.adapter.screenshotSelector) await page.waitForSelector(ctx.adapter.screenshotSelector);
|
|
108
|
+
return await page.screenshot();
|
|
109
|
+
} finally {
|
|
110
|
+
await page.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
//#endregion
|
|
114
|
+
export { createPlaywrightCaptureRunner };
|
|
115
|
+
|
|
116
|
+
//# sourceMappingURL=capture-runner.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capture-runner.mjs","names":[],"sources":["../src/capture-runner.ts"],"sourcesContent":["import { chromium, type Browser } from \"playwright\";\nimport type { Logger } from \"pino\";\n\nimport {\n StorybookAdapter,\n type CaptureRunner,\n type RenderResult,\n type RenderedSnapshot,\n type StoryEntry,\n type StorySourceAdapter,\n type Viewport,\n} from \"@storyshelf/core\";\n\ndeclare const __PKG_VERSION__: string;\n\nimport { createStaticServer } from \"./static-server.ts\";\n\ninterface ScreenshotContext {\n browser: Browser;\n adapter: StorySourceAdapter;\n baseUrl: string;\n}\n\n/** A render that may currently be in flight, so that `cancel` can abort it. */\ninterface ActiveRun {\n cancelled: boolean;\n browser: Browser | null;\n}\n\nconst activeRuns = new Map<string, ActiveRun>();\n\nexport interface PlaywrightRenderInput {\n buildId: string;\n storybookDir: string;\n stories: StoryEntry[];\n viewports: Viewport[];\n logger?: Logger;\n}\n\nexport function createPlaywrightCaptureRunner(): CaptureRunner {\n return {\n metadata: {\n name: \"Playwright\",\n version: typeof __PKG_VERSION__ === \"undefined\" ? \"0.0.0\" : __PKG_VERSION__, // oxlint-disable-line unicorn/no-typeof-undefined\n description: \"Playwright capture runner\",\n kind: \"playwright\",\n },\n async render(input: PlaywrightRenderInput) {\n const active: ActiveRun = { cancelled: false, browser: null };\n activeRuns.set(input.buildId, active);\n try {\n return await renderAll(input, active);\n } finally {\n activeRuns.delete(input.buildId);\n }\n },\n async cancel(buildId) {\n const active = activeRuns.get(buildId);\n if (!active) {\n return;\n }\n active.cancelled = true;\n await closeBrowser(active.browser);\n },\n };\n}\n\nasync function closeBrowser(browser: Browser | null): Promise<void> {\n if (!browser) {\n return;\n }\n try {\n await browser.close();\n } catch {\n // The run's own `finally` performs final cleanup; cancel must never throw.\n }\n}\n\nasync function renderAll(input: PlaywrightRenderInput, active: ActiveRun): Promise<RenderResult> {\n const server = await createStaticServer(input.storybookDir);\n const browser = await chromium.launch();\n active.browser = browser;\n const adapter = new StorybookAdapter();\n const ctx: ScreenshotContext = { browser, adapter, baseUrl: server.url };\n const captures: RenderedSnapshot[] = [];\n const failures: RenderResult[\"failures\"] = [];\n try {\n const tasks = input.viewports.flatMap((viewport) =>\n input.stories.map(async (story) => {\n if (active.cancelled) {\n throw new Error(\"Capture cancelled\");\n }\n try {\n const screenshot = await captureScreenshot(ctx, story, viewport);\n captures.push({ story, viewportName: viewport.name, screenshot });\n } catch (error) {\n failures.push({ storyId: story.id, viewportName: viewport.name, error: messageOf(error) });\n input.logger?.error({ storyId: story.id, viewport: viewport.name, err: error }, \"render failed for story\");\n }\n }),\n );\n await Promise.all(tasks);\n return { captures, failures };\n } finally {\n active.browser = null;\n await Promise.all([safeCloseBrowser(browser), safeCloseServer(server)]);\n }\n}\n\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nasync function safeCloseBrowser(browser: Browser): Promise<void> {\n try {\n await browser.close();\n } catch {\n // Already closed by `cancel`; the run must complete without throwing.\n }\n}\n\nasync function safeCloseServer(server: { close(): Promise<void> }): Promise<void> {\n try {\n await server.close();\n } catch {\n // Best-effort; teardown must never mask a render result.\n }\n}\n\nasync function captureScreenshot(ctx: ScreenshotContext, story: StoryEntry, viewport: Viewport): Promise<Buffer> {\n const page = await ctx.browser.newPage({ viewport: { width: viewport.width, height: viewport.height } });\n try {\n await page.goto(ctx.adapter.buildUrl(ctx.baseUrl, story.id), { waitUntil: \"networkidle\" });\n if (ctx.adapter.screenshotSelector) {\n await page.waitForSelector(ctx.adapter.screenshotSelector);\n }\n return await page.screenshot();\n } finally {\n await page.close();\n }\n}\n"],"mappings":";;;;;;;AA6BA,MAAM,6BAAa,IAAI,IAAuB;AAU9C,SAAgB,gCAA+C;CAC7D,OAAO;EACL,UAAU;GACR,MAAM;GACN,SAAA;GACA,aAAa;GACb,MAAM;EACR;EACA,MAAM,OAAO,OAA8B;GACzC,MAAM,SAAoB;IAAE,WAAW;IAAO,SAAS;GAAK;GAC5D,WAAW,IAAI,MAAM,SAAS,MAAM;GACpC,IAAI;IACF,OAAO,MAAM,UAAU,OAAO,MAAM;GACtC,UAAU;IACR,WAAW,OAAO,MAAM,OAAO;GACjC;EACF;EACA,MAAM,OAAO,SAAS;GACpB,MAAM,SAAS,WAAW,IAAI,OAAO;GACrC,IAAI,CAAC,QACH;GAEF,OAAO,YAAY;GACnB,MAAM,aAAa,OAAO,OAAO;EACnC;CACF;AACF;AAEA,eAAe,aAAa,SAAwC;CAClE,IAAI,CAAC,SACH;CAEF,IAAI;EACF,MAAM,QAAQ,MAAM;CACtB,QAAQ,CAER;AACF;AAEA,eAAe,UAAU,OAA8B,QAA0C;CAC/F,MAAM,SAAS,MAAM,mBAAmB,MAAM,YAAY;CAC1D,MAAM,UAAU,MAAM,SAAS,OAAO;CACtC,OAAO,UAAU;CAEjB,MAAM,MAAyB;EAAE;EAAS,SAAA,IADtB,iBAC4B;EAAG,SAAS,OAAO;CAAI;CACvE,MAAM,WAA+B,CAAC;CACtC,MAAM,WAAqC,CAAC;CAC5C,IAAI;EACF,MAAM,QAAQ,MAAM,UAAU,SAAS,aACrC,MAAM,QAAQ,IAAI,OAAO,UAAU;GACjC,IAAI,OAAO,WACT,MAAM,IAAI,MAAM,mBAAmB;GAErC,IAAI;IACF,MAAM,aAAa,MAAM,kBAAkB,KAAK,OAAO,QAAQ;IAC/D,SAAS,KAAK;KAAE;KAAO,cAAc,SAAS;KAAM;IAAW,CAAC;GAClE,SAAS,OAAO;IACd,SAAS,KAAK;KAAE,SAAS,MAAM;KAAI,cAAc,SAAS;KAAM,OAAO,UAAU,KAAK;IAAE,CAAC;IACzF,MAAM,QAAQ,MAAM;KAAE,SAAS,MAAM;KAAI,UAAU,SAAS;KAAM,KAAK;IAAM,GAAG,yBAAyB;GAC3G;EACF,CAAC,CACH;EACA,MAAM,QAAQ,IAAI,KAAK;EACvB,OAAO;GAAE;GAAU;EAAS;CAC9B,UAAU;EACR,OAAO,UAAU;EACjB,MAAM,QAAQ,IAAI,CAAC,iBAAiB,OAAO,GAAG,gBAAgB,MAAM,CAAC,CAAC;CACxE;AACF;AAEA,SAAS,UAAU,OAAwB;CACzC,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D;AAEA,eAAe,iBAAiB,SAAiC;CAC/D,IAAI;EACF,MAAM,QAAQ,MAAM;CACtB,QAAQ,CAER;AACF;AAEA,eAAe,gBAAgB,QAAmD;CAChF,IAAI;EACF,MAAM,OAAO,MAAM;CACrB,QAAQ,CAER;AACF;AAEA,eAAe,kBAAkB,KAAwB,OAAmB,UAAqC;CAC/G,MAAM,OAAO,MAAM,IAAI,QAAQ,QAAQ,EAAE,UAAU;EAAE,OAAO,SAAS;EAAO,QAAQ,SAAS;CAAO,EAAE,CAAC;CACvG,IAAI;EACF,MAAM,KAAK,KAAK,IAAI,QAAQ,SAAS,IAAI,SAAS,MAAM,EAAE,GAAG,EAAE,WAAW,cAAc,CAAC;EACzF,IAAI,IAAI,QAAQ,oBACd,MAAM,KAAK,gBAAgB,IAAI,QAAQ,kBAAkB;EAE3D,OAAO,MAAM,KAAK,WAAW;CAC/B,UAAU;EACR,MAAM,KAAK,MAAM;CACnB;AACF"}
|
package/dist/index.d.mts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { stat } from "node:fs/promises";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { extname, join, normalize, sep } from "node:path";
|
|
8
|
+
//#region src/static-server.ts
|
|
9
|
+
const MIME_TYPES = {
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
".html": "text/html; charset=utf-8",
|
|
12
|
+
".ico": "image/x-icon",
|
|
13
|
+
".jpeg": "image/jpeg",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".js": "text/javascript; charset=utf-8",
|
|
16
|
+
".json": "application/json; charset=utf-8",
|
|
17
|
+
".map": "application/json; charset=utf-8",
|
|
18
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
19
|
+
".png": "image/png",
|
|
20
|
+
".svg": "image/svg+xml"
|
|
21
|
+
};
|
|
22
|
+
function mimeFor(file) {
|
|
23
|
+
return MIME_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
24
|
+
}
|
|
25
|
+
function sendNotFound(res) {
|
|
26
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
27
|
+
res.end("Not found");
|
|
28
|
+
}
|
|
29
|
+
async function resolveFile(root, urlPath) {
|
|
30
|
+
const clean = (urlPath.split("?")[0] ?? "/").replace(/^\/+/u, "");
|
|
31
|
+
const target = normalize(join(root, clean === "" ? "index.html" : clean));
|
|
32
|
+
if (target !== root && !target.startsWith(root + sep)) return null;
|
|
33
|
+
const info = await stat(target).catch(() => null);
|
|
34
|
+
if (!info) return null;
|
|
35
|
+
if (info.isDirectory()) return resolveFile(root, `${clean}/index.html`);
|
|
36
|
+
return info.isFile() ? target : null;
|
|
37
|
+
}
|
|
38
|
+
async function handleRequest(root, url, res) {
|
|
39
|
+
const file = await resolveFile(root, url);
|
|
40
|
+
if (!file) {
|
|
41
|
+
sendNotFound(res);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
res.writeHead(200, { "content-type": mimeFor(file) });
|
|
45
|
+
createReadStream(file).pipe(res);
|
|
46
|
+
}
|
|
47
|
+
async function listen(server) {
|
|
48
|
+
return await new Promise((resolve, reject) => {
|
|
49
|
+
server.once("error", reject);
|
|
50
|
+
server.listen(0, "127.0.0.1", () => {
|
|
51
|
+
const address = server.address();
|
|
52
|
+
resolve(typeof address === "object" && address !== null ? address.port : 0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function closeServer(server) {
|
|
57
|
+
await new Promise((resolve, reject) => {
|
|
58
|
+
server.close((error) => {
|
|
59
|
+
if (error) reject(error);
|
|
60
|
+
else resolve();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function createStaticServer(rootDir) {
|
|
65
|
+
const root = normalize(rootDir);
|
|
66
|
+
const info = await stat(root).catch(() => null);
|
|
67
|
+
if (!info || !info.isDirectory()) throw new Error(`Storybook directory not found: ${rootDir}. Upload the built Storybook before capturing.`);
|
|
68
|
+
const server = createServer((req, res) => {
|
|
69
|
+
handleRequest(root, req.url ?? "/", res).catch(() => {
|
|
70
|
+
sendNotFound(res);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
url: `http://127.0.0.1:${await listen(server)}`,
|
|
75
|
+
close: async () => {
|
|
76
|
+
await closeServer(server);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
export { createStaticServer };
|
|
82
|
+
|
|
83
|
+
//# sourceMappingURL=static-server.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"static-server.mjs","names":[],"sources":["../src/static-server.ts"],"sourcesContent":["import { createReadStream } from \"node:fs\";\nimport { stat } from \"node:fs/promises\";\nimport { createServer, type Server, type ServerResponse } from \"node:http\";\nimport { extname, join, normalize, sep } from \"node:path\";\n\nexport interface StaticServer {\n url: string;\n close(): Promise<void>;\n}\n\nconst MIME_TYPES: Record<string, string> = {\n \".css\": \"text/css; charset=utf-8\",\n \".html\": \"text/html; charset=utf-8\",\n \".ico\": \"image/x-icon\",\n \".jpeg\": \"image/jpeg\",\n \".jpg\": \"image/jpeg\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".json\": \"application/json; charset=utf-8\",\n \".map\": \"application/json; charset=utf-8\",\n \".mjs\": \"text/javascript; charset=utf-8\",\n \".png\": \"image/png\",\n \".svg\": \"image/svg+xml\",\n};\n\nfunction mimeFor(file: string): string {\n return MIME_TYPES[extname(file).toLowerCase()] ?? \"application/octet-stream\";\n}\n\nfunction sendNotFound(res: ServerResponse): void {\n res.writeHead(404, { \"content-type\": \"text/plain\" });\n res.end(\"Not found\");\n}\n\nasync function resolveFile(root: string, urlPath: string): Promise<string | null> {\n const clean = (urlPath.split(\"?\")[0] ?? \"/\").replace(/^\\/+/u, \"\");\n const target = normalize(join(root, clean === \"\" ? \"index.html\" : clean));\n if (target !== root && !target.startsWith(root + sep)) {\n return null;\n }\n const info = await stat(target).catch(() => null);\n if (!info) {\n return null;\n }\n if (info.isDirectory()) {\n return resolveFile(root, `${clean}/index.html`);\n }\n return info.isFile() ? target : null;\n}\n\nasync function handleRequest(root: string, url: string, res: ServerResponse): Promise<void> {\n const file = await resolveFile(root, url);\n if (!file) {\n sendNotFound(res);\n return;\n }\n res.writeHead(200, { \"content-type\": mimeFor(file) });\n createReadStream(file).pipe(res);\n}\n\nasync function listen(server: Server): Promise<number> {\n return await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const address = server.address();\n resolve(typeof address === \"object\" && address !== null ? address.port : 0);\n });\n });\n}\n\nasync function closeServer(server: Server): Promise<void> {\n await new Promise<void>((resolve, reject) => {\n server.close((error) => {\n if (error) {\n reject(error);\n } else {\n resolve();\n }\n });\n });\n}\n\nexport async function createStaticServer(rootDir: string): Promise<StaticServer> {\n const root = normalize(rootDir);\n const info = await stat(root).catch(() => null);\n if (!info || !info.isDirectory()) {\n throw new Error(`Storybook directory not found: ${rootDir}. Upload the built Storybook before capturing.`);\n }\n\n const server = createServer((req, res) => {\n handleRequest(root, req.url ?? \"/\", res).catch(() => {\n sendNotFound(res);\n });\n });\n\n const port = await listen(server);\n\n return {\n url: `http://127.0.0.1:${port}`,\n close: async () => {\n await closeServer(server);\n },\n };\n}\n"],"mappings":";;;;;;;;AAUA,MAAM,aAAqC;CACzC,QAAQ;CACR,SAAS;CACT,QAAQ;CACR,SAAS;CACT,QAAQ;CACR,OAAO;CACP,SAAS;CACT,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,QAAQ;AACV;AAEA,SAAS,QAAQ,MAAsB;CACrC,OAAO,WAAW,QAAQ,IAAI,CAAC,CAAC,YAAY,MAAM;AACpD;AAEA,SAAS,aAAa,KAA2B;CAC/C,IAAI,UAAU,KAAK,EAAE,gBAAgB,aAAa,CAAC;CACnD,IAAI,IAAI,WAAW;AACrB;AAEA,eAAe,YAAY,MAAc,SAAyC;CAChF,MAAM,SAAS,QAAQ,MAAM,GAAG,CAAC,CAAC,MAAM,IAAA,CAAK,QAAQ,SAAS,EAAE;CAChE,MAAM,SAAS,UAAU,KAAK,MAAM,UAAU,KAAK,eAAe,KAAK,CAAC;CACxE,IAAI,WAAW,QAAQ,CAAC,OAAO,WAAW,OAAO,GAAG,GAClD,OAAO;CAET,MAAM,OAAO,MAAM,KAAK,MAAM,CAAC,CAAC,YAAY,IAAI;CAChD,IAAI,CAAC,MACH,OAAO;CAET,IAAI,KAAK,YAAY,GACnB,OAAO,YAAY,MAAM,GAAG,MAAM,YAAY;CAEhD,OAAO,KAAK,OAAO,IAAI,SAAS;AAClC;AAEA,eAAe,cAAc,MAAc,KAAa,KAAoC;CAC1F,MAAM,OAAO,MAAM,YAAY,MAAM,GAAG;CACxC,IAAI,CAAC,MAAM;EACT,aAAa,GAAG;EAChB;CACF;CACA,IAAI,UAAU,KAAK,EAAE,gBAAgB,QAAQ,IAAI,EAAE,CAAC;CACpD,iBAAiB,IAAI,CAAC,CAAC,KAAK,GAAG;AACjC;AAEA,eAAe,OAAO,QAAiC;CACrD,OAAO,MAAM,IAAI,SAAS,SAAS,WAAW;EAC5C,OAAO,KAAK,SAAS,MAAM;EAC3B,OAAO,OAAO,GAAG,mBAAmB;GAClC,MAAM,UAAU,OAAO,QAAQ;GAC/B,QAAQ,OAAO,YAAY,YAAY,YAAY,OAAO,QAAQ,OAAO,CAAC;EAC5E,CAAC;CACH,CAAC;AACH;AAEA,eAAe,YAAY,QAA+B;CACxD,MAAM,IAAI,SAAe,SAAS,WAAW;EAC3C,OAAO,OAAO,UAAU;GACtB,IAAI,OACF,OAAO,KAAK;QAEZ,QAAQ;EAEZ,CAAC;CACH,CAAC;AACH;AAEA,eAAsB,mBAAmB,SAAwC;CAC/E,MAAM,OAAO,UAAU,OAAO;CAC9B,MAAM,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,YAAY,IAAI;CAC9C,IAAI,CAAC,QAAQ,CAAC,KAAK,YAAY,GAC7B,MAAM,IAAI,MAAM,kCAAkC,QAAQ,+CAA+C;CAG3G,MAAM,SAAS,cAAc,KAAK,QAAQ;EACxC,cAAc,MAAM,IAAI,OAAO,KAAK,GAAG,CAAC,CAAC,YAAY;GACnD,aAAa,GAAG;EAClB,CAAC;CACH,CAAC;CAID,OAAO;EACL,KAAK,oBAAoB,MAHR,OAAO,MAAM;EAI9B,OAAO,YAAY;GACjB,MAAM,YAAY,MAAM;EAC1B;CACF;AACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@storyshelf/runner-playwright",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pure Playwright CaptureRunner implementation for StoryShelf: renders Storybook screenshots in-process (no db/storage coupling).",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Siddhant Gupta",
|
|
8
|
+
"url": "https://guptasiddhant.com"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/GuptaSiddhant/storyshelf.git",
|
|
15
|
+
"directory": "packages/runner-playwright"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/GuptaSiddhant/storyshelf#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/GuptaSiddhant/storyshelf/issues"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./dist/index.mjs",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown",
|
|
33
|
+
"dev": "tsdown -w",
|
|
34
|
+
"fmt": "oxfmt -c ../../.oxfmtrc.json ./src",
|
|
35
|
+
"lint": "oxlint --type-aware --type-check ./src",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:integration": "RUN_INTEGRATION=1 vitest run integration.test.ts",
|
|
38
|
+
"prepublishOnly": "nub run build"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@storyshelf/core": "workspace:*",
|
|
42
|
+
"adm-zip": "^0.6.0",
|
|
43
|
+
"pino": "^10.3.1",
|
|
44
|
+
"playwright": "^1.52.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@storyshelf/db-sqlite": "workspace:*",
|
|
48
|
+
"@storyshelf/storage-local": "workspace:*",
|
|
49
|
+
"@types/node": "catalog:",
|
|
50
|
+
"@vitest/coverage-v8": "catalog:",
|
|
51
|
+
"oxfmt": "catalog:",
|
|
52
|
+
"oxlint": "catalog:",
|
|
53
|
+
"oxlint-tsgolint": "catalog:",
|
|
54
|
+
"tsdown": "catalog:",
|
|
55
|
+
"typescript": "catalog:",
|
|
56
|
+
"vitest": "catalog:"
|
|
57
|
+
},
|
|
58
|
+
"types": "./dist/index.d.mts",
|
|
59
|
+
"exports": {
|
|
60
|
+
".": {
|
|
61
|
+
"source": "./src/index.ts",
|
|
62
|
+
"default": "./dist/index.mjs"
|
|
63
|
+
},
|
|
64
|
+
"./package.json": "./package.json"
|
|
65
|
+
}
|
|
66
|
+
}
|