@crvy/strybk 0.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Crvy Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # strybk
2
+
3
+ Generator-first Playwright screenshot testing for Storybook.
4
+
5
+ ## Development
6
+
7
+ Install dependencies with Bun:
8
+
9
+ ```sh
10
+ bun install
11
+ ```
12
+
13
+ Common local commands:
14
+
15
+ ```sh
16
+ bun run lint
17
+ bun run typecheck
18
+ bun run test:bun
19
+ bun run build
20
+ bun run check
21
+ ```
22
+
23
+ ## CLI
24
+
25
+ Generate screenshot specs from a Storybook index:
26
+
27
+ ```sh
28
+ strybk generate --config ./strybk.config.ts
29
+ ```
30
+
31
+ Use `--dry-run` to compute outputs without writing files:
32
+
33
+ ```sh
34
+ strybk generate --config ./strybk.config.ts --dry-run
35
+ ```
36
+
37
+ The config module should export a `StrybkConfig` object, typically as the default export from `defineConfig(...)`.
38
+
39
+ ## Changelog
40
+
41
+ Preview the next changelog entry:
42
+
43
+ ```sh
44
+ bun run changelog:preview
45
+ ```
46
+
47
+ Regenerate the full changelog from git history:
48
+
49
+ ```sh
50
+ bun run changelog:generate
51
+ ```
52
+
53
+ ## Local Linking
54
+
55
+ Build the package:
56
+
57
+ ```sh
58
+ bun run build
59
+ ```
60
+
61
+ Register this package for local linking:
62
+
63
+ ```sh
64
+ cd /path/to/strybk
65
+ bun link
66
+ ```
67
+
68
+ Link it into the consumer project:
69
+
70
+ ```sh
71
+ cd /path/to/consumer-project
72
+ bun link strybk
73
+ ```
74
+
75
+ If you want to persist the link in the consumer's manifest, Bun supports a `link:` dependency entry:
76
+
77
+ ```json
78
+ {
79
+ "dependencies": {
80
+ "strybk": "link:strybk"
81
+ }
82
+ }
83
+ ```
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ export interface GenerateCliArgs {
3
+ command: "generate";
4
+ configPath: string;
5
+ dryRun: boolean;
6
+ }
7
+ export declare function parseCliArgs(argv: string[]): GenerateCliArgs;
8
+ export declare function runCli(argv: string[]): Promise<Array<{
9
+ outputPath: string;
10
+ content: string;
11
+ }>>;
12
+ export declare function main(argv?: string[]): Promise<void>;
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { generateScreenshots } from "./generate/index.js";
6
+ const isRecord = (value) => typeof value === "object" && value !== null;
7
+ const asUnknown = (value) => value;
8
+ const toErrorMessage = (error) => error instanceof Error ? error.message : String(error);
9
+ const readExistingFile = (filePath) => {
10
+ try {
11
+ return readFileSync(filePath, "utf8");
12
+ }
13
+ catch (error) {
14
+ if (isRecord(error) && error.code === "ENOENT") {
15
+ return null;
16
+ }
17
+ throw error;
18
+ }
19
+ };
20
+ const resolveConfigExport = (moduleNamespace) => {
21
+ if (!isRecord(moduleNamespace)) {
22
+ return moduleNamespace;
23
+ }
24
+ if ("default" in moduleNamespace) {
25
+ return moduleNamespace.default;
26
+ }
27
+ if ("config" in moduleNamespace) {
28
+ return moduleNamespace.config;
29
+ }
30
+ return moduleNamespace;
31
+ };
32
+ const isStrybkConfig = (value) => isRecord(value) &&
33
+ typeof value.storybookUrl === "string" &&
34
+ Array.isArray(value.storyGlobs) &&
35
+ typeof value.resolveSpecPath === "function" &&
36
+ typeof value.resolveHarnessImports === "function";
37
+ const getIndexEntriesRecord = (payload) => {
38
+ if (!isRecord(payload)) {
39
+ throw new Error("Storybook index response must be an object");
40
+ }
41
+ const indexPayload = payload;
42
+ const entries = indexPayload.entries ?? indexPayload.stories;
43
+ if (!isRecord(entries)) {
44
+ throw new Error("Storybook index response must include an entries object");
45
+ }
46
+ return entries;
47
+ };
48
+ const toStoryIndexEntry = (value) => {
49
+ if (!isRecord(value)) {
50
+ return null;
51
+ }
52
+ if ("type" in value && value.type !== undefined && value.type !== "story") {
53
+ return null;
54
+ }
55
+ if (typeof value.id !== "string" ||
56
+ typeof value.title !== "string" ||
57
+ typeof value.name !== "string") {
58
+ return null;
59
+ }
60
+ return {
61
+ id: value.id,
62
+ title: value.title,
63
+ name: value.name,
64
+ importPath: typeof value.importPath === "string" ? value.importPath : undefined,
65
+ exportName: typeof value.exportName === "string" ? value.exportName : undefined,
66
+ };
67
+ };
68
+ const resolveStorybookIndexUrl = (storybookUrl) => new URL("./index.json", storybookUrl.endsWith("/") ? storybookUrl : `${storybookUrl}/`);
69
+ const loadConfig = async (configPath) => {
70
+ const resolvedConfigPath = resolve(configPath);
71
+ const moduleNamespace = await import(pathToFileURL(resolvedConfigPath).href);
72
+ const config = resolveConfigExport(moduleNamespace);
73
+ if (!isStrybkConfig(config)) {
74
+ throw new Error(`Config module '${resolvedConfigPath}' must export a StrybkConfig object`);
75
+ }
76
+ return config;
77
+ };
78
+ const fetchStoryIndex = async (config) => {
79
+ const indexUrl = resolveStorybookIndexUrl(config.storybookUrl);
80
+ const response = await fetch(indexUrl);
81
+ if (!response.ok) {
82
+ throw new Error(`Failed to fetch ${indexUrl.toString()}: ${response.status} ${response.statusText}`.trim());
83
+ }
84
+ const payload = asUnknown(await response.json());
85
+ const entries = getIndexEntriesRecord(payload);
86
+ return Object.values(entries)
87
+ .map(toStoryIndexEntry)
88
+ .filter((entry) => entry !== null);
89
+ };
90
+ const writeGeneratedFiles = (outputs) => {
91
+ for (const output of outputs) {
92
+ mkdirSync(dirname(output.outputPath), { recursive: true });
93
+ writeFileSync(output.outputPath, output.content);
94
+ }
95
+ };
96
+ export function parseCliArgs(argv) {
97
+ const [command, ...options] = argv;
98
+ if (command !== "generate") {
99
+ throw new Error("Expected 'generate' command");
100
+ }
101
+ let configPath;
102
+ let dryRun = false;
103
+ for (let index = 0; index < options.length; index += 1) {
104
+ const option = options[index];
105
+ if (option === "--dry-run") {
106
+ dryRun = true;
107
+ continue;
108
+ }
109
+ if (option === "--config") {
110
+ const nextOption = options[index + 1];
111
+ if (!nextOption || nextOption.startsWith("--")) {
112
+ throw new Error("Missing value for --config option");
113
+ }
114
+ configPath = nextOption;
115
+ index += 1;
116
+ continue;
117
+ }
118
+ if (option.startsWith("--config=")) {
119
+ configPath = option.slice("--config=".length);
120
+ if (configPath.length === 0) {
121
+ throw new Error("Missing value for --config option");
122
+ }
123
+ continue;
124
+ }
125
+ throw new Error(`Unknown option: ${option}`);
126
+ }
127
+ if (configPath === undefined) {
128
+ throw new Error("Missing required --config option");
129
+ }
130
+ return {
131
+ command: "generate",
132
+ configPath,
133
+ dryRun,
134
+ };
135
+ }
136
+ export async function runCli(argv) {
137
+ const cliArgs = parseCliArgs(argv);
138
+ const config = await loadConfig(cliArgs.configPath);
139
+ const indexEntries = await fetchStoryIndex(config);
140
+ const outputs = await generateScreenshots({
141
+ config,
142
+ indexEntries,
143
+ readExistingFile,
144
+ });
145
+ if (!cliArgs.dryRun) {
146
+ writeGeneratedFiles(outputs);
147
+ }
148
+ return outputs;
149
+ }
150
+ export async function main(argv = process.argv.slice(2)) {
151
+ try {
152
+ const outputs = await runCli(argv);
153
+ const dryRun = argv.includes("--dry-run");
154
+ const suffix = dryRun ? " (dry run)" : "";
155
+ console.log(`Generated ${outputs.length} file(s)${suffix}.`);
156
+ }
157
+ catch (error) {
158
+ console.error(toErrorMessage(error));
159
+ process.exitCode = 1;
160
+ }
161
+ }
162
+ const maybeEntrypoint = process.argv[1];
163
+ if (maybeEntrypoint !== undefined) {
164
+ try {
165
+ const currentFilePath = realpathSync(fileURLToPath(import.meta.url));
166
+ const invokedFilePath = realpathSync(resolve(maybeEntrypoint));
167
+ if (currentFilePath === invokedFilePath) {
168
+ void main();
169
+ }
170
+ }
171
+ catch {
172
+ // Ignore resolution failures during module import.
173
+ }
174
+ }
@@ -0,0 +1,20 @@
1
+ export interface StorybookGlobals {
2
+ [key: string]: string | number | boolean | null | undefined;
3
+ }
4
+ export interface StrybkConfig {
5
+ storybookUrl: string;
6
+ storyGlobs: string[];
7
+ resolveSpecPath: (args: {
8
+ storyFilePath: string;
9
+ }) => string;
10
+ resolveHarnessImports: (args: {
11
+ outputPath: string;
12
+ }) => {
13
+ fixturesImport: string;
14
+ switchStoryImport: string;
15
+ };
16
+ generatedRegionName?: string;
17
+ deleteOrphans?: boolean;
18
+ metadataExtractors?: "creevey"[];
19
+ }
20
+ export declare function defineConfig(config: StrybkConfig): StrybkConfig;
@@ -0,0 +1,8 @@
1
+ export function defineConfig(config) {
2
+ return {
3
+ generatedRegionName: "auto-screenshots",
4
+ deleteOrphans: true,
5
+ metadataExtractors: [],
6
+ ...config,
7
+ };
8
+ }
@@ -0,0 +1,5 @@
1
+ export interface StoryFile {
2
+ filePath: string;
3
+ title: string;
4
+ }
5
+ export declare function discoverStoryFiles(patterns: string[]): Promise<StoryFile[]>;
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { glob } from "glob";
3
+ export async function discoverStoryFiles(patterns) {
4
+ const files = await glob(patterns, { absolute: true });
5
+ return files.flatMap((filePath) => {
6
+ const content = readFileSync(filePath, "utf8");
7
+ const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/u);
8
+ if (!titleMatch) {
9
+ return [];
10
+ }
11
+ return [{ filePath, title: titleMatch[1] }];
12
+ });
13
+ }
@@ -0,0 +1,16 @@
1
+ import type { StrybkConfig } from "../config.js";
2
+ export interface StoryIndexEntry {
3
+ id: string;
4
+ title: string;
5
+ name: string;
6
+ importPath?: string;
7
+ exportName?: string;
8
+ }
9
+ export declare function generateScreenshots(args: {
10
+ config: StrybkConfig;
11
+ indexEntries: StoryIndexEntry[];
12
+ readExistingFile?: (filePath: string) => string | null;
13
+ }): Promise<Array<{
14
+ outputPath: string;
15
+ content: string;
16
+ }>>;
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { discoverStoryFiles } from "./discover.js";
3
+ import { extractCreeveyMetadata, FILE_POLICY_KEY } from "./metadata.js";
4
+ import { renderScreenshotSpec } from "./render.js";
5
+ const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
6
+ const toStoryIdSegment = (value) => value
7
+ .replace(/([a-z0-9])([A-Z])/gu, "$1-$2")
8
+ .replace(/[^a-zA-Z0-9]+/gu, "-")
9
+ .replace(/^-+|-+$/gu, "")
10
+ .toLowerCase();
11
+ const isStorySkipped = (story, storyMetadata) => {
12
+ if (story.exportName !== undefined) {
13
+ return storyMetadata[story.exportName]?.skip === true;
14
+ }
15
+ const storyIdSegment = story.id.split("--").slice(1).join("--");
16
+ return Object.entries(storyMetadata).some(([exportName, policy]) => policy.skip === true && toStoryIdSegment(exportName) === storyIdSegment);
17
+ };
18
+ export async function generateScreenshots(args) {
19
+ const storyFiles = await discoverStoryFiles(args.config.storyGlobs);
20
+ const generatedRegionName = args.config.generatedRegionName ?? "auto-screenshots";
21
+ const manualRegionPattern = new RegExp(`// @generated-end ${escapeForRegExp(generatedRegionName)}\\s*([\\s\\S]*)$`, "u");
22
+ const shouldExtractCreeveyMetadata = args.config.metadataExtractors?.includes("creevey") ?? false;
23
+ return storyFiles.flatMap((storyFile) => {
24
+ const stories = args.indexEntries.filter((entry) => entry.title === storyFile.title);
25
+ const storyMetadata = shouldExtractCreeveyMetadata
26
+ ? extractCreeveyMetadata(readFileSync(storyFile.filePath, "utf8"))
27
+ : {};
28
+ const isFileSkipped = storyMetadata[FILE_POLICY_KEY]?.skip === true;
29
+ const filteredStories = isFileSkipped
30
+ ? []
31
+ : stories.filter((story) => !isStorySkipped(story, storyMetadata));
32
+ const outputPath = args.config.resolveSpecPath({ storyFilePath: storyFile.filePath });
33
+ const existing = args.readExistingFile?.(outputPath) ?? null;
34
+ const manualRegion = existing?.match(manualRegionPattern)?.[1]?.trim() ?? "";
35
+ if (filteredStories.length === 0 && manualRegion.length === 0) {
36
+ return [];
37
+ }
38
+ const harnessImports = args.config.resolveHarnessImports({ outputPath });
39
+ return [
40
+ {
41
+ outputPath,
42
+ content: renderScreenshotSpec({
43
+ config: args.config,
44
+ fixturesImport: harnessImports.fixturesImport,
45
+ switchStoryImport: harnessImports.switchStoryImport,
46
+ title: storyFile.title,
47
+ stories: filteredStories,
48
+ manualRegion,
49
+ }),
50
+ },
51
+ ];
52
+ });
53
+ }
@@ -0,0 +1,5 @@
1
+ export interface StoryPolicy {
2
+ skip?: boolean;
3
+ }
4
+ export declare const FILE_POLICY_KEY = "__file__";
5
+ export declare function extractCreeveyMetadata(source: string): Record<string, StoryPolicy>;
@@ -0,0 +1,124 @@
1
+ export const FILE_POLICY_KEY = "__file__";
2
+ const skipWhitespace = (source, startIndex) => {
3
+ let index = startIndex;
4
+ while (/\s/u.test(source[index] ?? "")) {
5
+ index += 1;
6
+ }
7
+ return index;
8
+ };
9
+ const extractObjectLiteral = (source, startIndex) => {
10
+ const objectStartIndex = skipWhitespace(source, startIndex);
11
+ if (source[objectStartIndex] !== "{") {
12
+ return null;
13
+ }
14
+ let depth = 0;
15
+ let inSingleQuote = false;
16
+ let inDoubleQuote = false;
17
+ let inTemplateString = false;
18
+ let inLineComment = false;
19
+ let inBlockComment = false;
20
+ for (let index = objectStartIndex; index < source.length; index += 1) {
21
+ const char = source[index] ?? "";
22
+ const nextChar = source[index + 1] ?? "";
23
+ const previousChar = source[index - 1] ?? "";
24
+ if (inLineComment) {
25
+ if (char === "\n") {
26
+ inLineComment = false;
27
+ }
28
+ continue;
29
+ }
30
+ if (inBlockComment) {
31
+ if (previousChar === "*" && char === "/") {
32
+ inBlockComment = false;
33
+ }
34
+ continue;
35
+ }
36
+ if (inSingleQuote) {
37
+ if (char === "'" && previousChar !== "\\") {
38
+ inSingleQuote = false;
39
+ }
40
+ continue;
41
+ }
42
+ if (inDoubleQuote) {
43
+ if (char === '"' && previousChar !== "\\") {
44
+ inDoubleQuote = false;
45
+ }
46
+ continue;
47
+ }
48
+ if (inTemplateString) {
49
+ if (char === "`" && previousChar !== "\\") {
50
+ inTemplateString = false;
51
+ }
52
+ continue;
53
+ }
54
+ if (char === "/" && nextChar === "/") {
55
+ inLineComment = true;
56
+ index += 1;
57
+ continue;
58
+ }
59
+ if (char === "/" && nextChar === "*") {
60
+ inBlockComment = true;
61
+ index += 1;
62
+ continue;
63
+ }
64
+ if (char === "'") {
65
+ inSingleQuote = true;
66
+ continue;
67
+ }
68
+ if (char === '"') {
69
+ inDoubleQuote = true;
70
+ continue;
71
+ }
72
+ if (char === "`") {
73
+ inTemplateString = true;
74
+ continue;
75
+ }
76
+ if (char === "{") {
77
+ depth += 1;
78
+ continue;
79
+ }
80
+ if (char === "}") {
81
+ depth -= 1;
82
+ if (depth === 0) {
83
+ return source.slice(objectStartIndex, index + 1);
84
+ }
85
+ }
86
+ }
87
+ return null;
88
+ };
89
+ const extractSkipPolicy = (source, scope) => {
90
+ if (source === null) {
91
+ return undefined;
92
+ }
93
+ const pattern = scope === "parameters"
94
+ ? /\bcreevey\s*:\s*\{[\s\S]*?\bskip\s*:\s*(true|false)\b/u
95
+ : /\bparameters\s*:\s*\{[\s\S]*?\bcreevey\s*:\s*\{[\s\S]*?\bskip\s*:\s*(true|false)\b/u;
96
+ const skipMatch = source.match(pattern);
97
+ return skipMatch ? { skip: skipMatch[1] === "true" } : undefined;
98
+ };
99
+ const collectPolicies = (source, pattern, scope) => Array.from(source.matchAll(pattern)).flatMap((match) => {
100
+ const index = match.index ?? 0;
101
+ const policy = extractSkipPolicy(extractObjectLiteral(source, index + match[0].length), scope);
102
+ return policy ? [[index, match[1], policy]] : [];
103
+ });
104
+ export function extractCreeveyMetadata(source) {
105
+ const storyPolicies = [
106
+ ...collectPolicies(source, /(\w+)\.parameters\s*=/gu, "parameters"),
107
+ ...collectPolicies(source, /export\s+const\s+(\w+)(?:\s*:\s*[^=]+)?\s*=/gu, "object"),
108
+ ];
109
+ const constPolicies = new Map(collectPolicies(source, /const\s+(\w+)(?:\s*:\s*[^=]+)?\s*=/gu, "object").map(([, name, policy]) => [name, policy]));
110
+ const filePolicies = [
111
+ ...Array.from(source.matchAll(/export\s+default\b/gu)).flatMap((match) => {
112
+ const index = match.index ?? 0;
113
+ const policy = extractSkipPolicy(extractObjectLiteral(source, index + match[0].length), "object");
114
+ return policy ? [[index, FILE_POLICY_KEY, policy]] : [];
115
+ }),
116
+ ...Array.from(source.matchAll(/export\s+default\s+(\w+)\s*;/gu)).flatMap((match) => {
117
+ const policy = constPolicies.get(match[1]);
118
+ return policy ? [[match.index ?? 0, FILE_POLICY_KEY, policy]] : [];
119
+ }),
120
+ ];
121
+ return Object.fromEntries([...storyPolicies, ...filePolicies]
122
+ .sort((left, right) => left[0] - right[0])
123
+ .map(([, name, policy]) => [name, policy]));
124
+ }
@@ -0,0 +1,13 @@
1
+ import type { StrybkConfig } from "../config.js";
2
+ export interface RenderableStory {
3
+ id: string;
4
+ name: string;
5
+ }
6
+ export declare function renderScreenshotSpec(args: {
7
+ config: StrybkConfig;
8
+ fixturesImport: string;
9
+ switchStoryImport: string;
10
+ title: string;
11
+ stories: RenderableStory[];
12
+ manualRegion: string;
13
+ }): string;
@@ -0,0 +1,8 @@
1
+ const escapeSingleQuotes = (value) => value.replace(/'/gu, "\\'");
2
+ export function renderScreenshotSpec(args) {
3
+ const generatedRegionName = args.config.generatedRegionName ?? "auto-screenshots";
4
+ const tests = args.stories
5
+ .map((story) => ` test('${escapeSingleQuotes(story.name)}', async ({ sharedPage }) => {\n await switchStory(sharedPage, '${story.id}');\n await expect(sharedPage).toHaveScreenshot();\n });`)
6
+ .join("\n\n");
7
+ return `import { test, expect } from '${args.fixturesImport}';\nimport { switchStory } from '${args.switchStoryImport}';\n\n// @generated-begin ${generatedRegionName}\ntest.describe('${escapeSingleQuotes(args.title)}', () => {\n${tests}\n});\n// @generated-end ${generatedRegionName}\n\n${args.manualRegion}`;
8
+ }
@@ -0,0 +1,4 @@
1
+ export { defineConfig } from "./config.js";
2
+ export type { StrybkConfig, StorybookGlobals } from "./config.js";
3
+ export { generateScreenshots } from "./generate/index.js";
4
+ export { createStrybkFixtures, switchStory } from "./playwright/index.js";
@@ -0,0 +1,3 @@
1
+ export { defineConfig } from "./config.js";
2
+ export { generateScreenshots } from "./generate/index.js";
3
+ export { createStrybkFixtures, switchStory } from "./playwright/index.js";
@@ -0,0 +1,11 @@
1
+ import type { Page, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestType } from "@playwright/test";
2
+ type StrybkFixtures = {
3
+ sharedPage: Page;
4
+ };
5
+ type PublicStrybkTest = TestType<PlaywrightTestArgs & PlaywrightTestOptions & StrybkFixtures, PlaywrightWorkerArgs & PlaywrightWorkerOptions>;
6
+ type StrybkFixtureHandles = {
7
+ expect: typeof import("@playwright/test").expect;
8
+ test: PublicStrybkTest;
9
+ };
10
+ export declare const createStrybkFixtures: () => StrybkFixtureHandles;
11
+ export {};
@@ -0,0 +1,84 @@
1
+ import { createChannelDriver } from "../storybook/channelDriver.js";
2
+ import { loadPlaywrightTestRuntime } from "./runtime.js";
3
+ const animationDisablerStyles = [
4
+ "*, *::before, *::after {",
5
+ " animation: none !important;",
6
+ " caret-color: transparent !important;",
7
+ " cursor: none !important;",
8
+ " transition: none !important;",
9
+ "}",
10
+ "html {",
11
+ " scroll-behavior: auto !important;",
12
+ "}",
13
+ ].join("\n");
14
+ const storybookReadyTimeoutMs = 10_000;
15
+ const channelDriver = createChannelDriver();
16
+ const isStorybookGlobalValue = (value) => value === null ||
17
+ value === undefined ||
18
+ typeof value === "boolean" ||
19
+ typeof value === "number" ||
20
+ typeof value === "string";
21
+ const getStorybookGlobals = (testInfo) => {
22
+ const metadata = testInfo.project.metadata;
23
+ const storybookGlobals = metadata?.storybookGlobals;
24
+ if (typeof storybookGlobals !== "object" ||
25
+ storybookGlobals === null ||
26
+ Array.isArray(storybookGlobals)) {
27
+ return undefined;
28
+ }
29
+ return Object.fromEntries(Object.entries(storybookGlobals).filter(([, value]) => isStorybookGlobalValue(value)));
30
+ };
31
+ const resolveIframeUrl = (baseURL) => baseURL !== undefined && baseURL.length > 0
32
+ ? new URL("/iframe.html", baseURL).toString()
33
+ : "/iframe.html";
34
+ const disableAnimations = async (page) => {
35
+ await page.addStyleTag({ content: animationDisablerStyles });
36
+ };
37
+ const resetSharedPage = async (page) => {
38
+ await page.mouse.move(0, 0);
39
+ await page.evaluate(() => {
40
+ window.scrollTo(0, 0);
41
+ document.documentElement.scrollTop = 0;
42
+ document.body.scrollTop = 0;
43
+ });
44
+ };
45
+ const restoreSharedPageBaseline = async (page, baseURL) => {
46
+ await page.goto(resolveIframeUrl(baseURL));
47
+ await page.waitForSelector("#storybook-root", {
48
+ state: "attached",
49
+ timeout: storybookReadyTimeoutMs,
50
+ });
51
+ await disableAnimations(page);
52
+ await resetSharedPage(page);
53
+ };
54
+ export const createStrybkFixtures = () => {
55
+ const { expect, test: base } = loadPlaywrightTestRuntime();
56
+ const test = base.extend({
57
+ _workerPage: [
58
+ async ({ browser }, use, workerInfo) => {
59
+ const context = await browser.newContext({
60
+ baseURL: workerInfo.project.use.baseURL,
61
+ viewport: workerInfo.project.use.viewport,
62
+ });
63
+ const page = await context.newPage();
64
+ await restoreSharedPageBaseline(page, workerInfo.project.use.baseURL);
65
+ await use(page);
66
+ await context.close();
67
+ },
68
+ { scope: "worker" },
69
+ ],
70
+ sharedPage: async ({ _workerPage }, use, testInfo) => {
71
+ const storybookGlobals = getStorybookGlobals(testInfo);
72
+ if (storybookGlobals !== undefined) {
73
+ await channelDriver.updateGlobals(_workerPage, storybookGlobals);
74
+ }
75
+ await resetSharedPage(_workerPage);
76
+ await use(_workerPage);
77
+ await restoreSharedPageBaseline(_workerPage, testInfo.project.use.baseURL);
78
+ },
79
+ });
80
+ return {
81
+ expect,
82
+ test: test,
83
+ };
84
+ };
@@ -0,0 +1,2 @@
1
+ export { createStrybkFixtures } from "./fixtures.js";
2
+ export { switchStory } from "./switchStory.js";
@@ -0,0 +1,2 @@
1
+ export { createStrybkFixtures } from "./fixtures.js";
2
+ export { switchStory } from "./switchStory.js";
@@ -0,0 +1 @@
1
+ export declare const loadPlaywrightTestRuntime: () => typeof import("@playwright/test");
@@ -0,0 +1,16 @@
1
+ import { createRequire } from "node:module";
2
+ import { resolve } from "node:path";
3
+ const isPlaywrightTestRuntime = (value) => {
4
+ if ((typeof value !== "object" && typeof value !== "function") || value === null) {
5
+ return false;
6
+ }
7
+ return "expect" in value && "test" in value;
8
+ };
9
+ export const loadPlaywrightTestRuntime = () => {
10
+ const requireFromCwd = createRequire(resolve(process.cwd(), "package.json"));
11
+ const runtime = requireFromCwd("@playwright/test");
12
+ if (!isPlaywrightTestRuntime(runtime)) {
13
+ throw new Error("Failed to load @playwright/test runtime");
14
+ }
15
+ return runtime;
16
+ };
@@ -0,0 +1,2 @@
1
+ import type { Page } from "@playwright/test";
2
+ export declare function switchStory(page: Page, storyId: string): Promise<void>;
@@ -0,0 +1,5 @@
1
+ import { createChannelDriver } from "../storybook/channelDriver.js";
2
+ const defaultDriver = createChannelDriver();
3
+ export async function switchStory(page, storyId) {
4
+ await defaultDriver.selectStory(page, storyId);
5
+ }
@@ -0,0 +1,2 @@
1
+ import type { StorybookDriver } from "./driver.js";
2
+ export declare function createChannelDriver(): StorybookDriver;
@@ -0,0 +1,73 @@
1
+ export function createChannelDriver() {
2
+ return {
3
+ async selectStory(page, storyId) {
4
+ await page.evaluate(async (currentStoryId) => {
5
+ const storySwitchTimeoutMs = 10_000;
6
+ const channel = window.__STORYBOOK_ADDONS_CHANNEL__;
7
+ if (!channel) {
8
+ throw new Error("Storybook addons channel is unavailable");
9
+ }
10
+ await new Promise((resolve, reject) => {
11
+ let settled = false;
12
+ const cleanup = () => {
13
+ channel.off("storyRendered", handleSuccess);
14
+ channel.off("storyUnchanged", handleSuccess);
15
+ channel.off("storyErrored", handleError);
16
+ };
17
+ const settle = (callback) => {
18
+ if (settled) {
19
+ return;
20
+ }
21
+ settled = true;
22
+ clearTimeout(timeout);
23
+ cleanup();
24
+ callback();
25
+ };
26
+ const handleSuccess = () => {
27
+ cleanup();
28
+ void (document.fonts?.ready ?? Promise.resolve())
29
+ .then(() => {
30
+ settle(() => {
31
+ resolve();
32
+ });
33
+ })
34
+ .catch((error) => {
35
+ settle(() => {
36
+ reject(error instanceof Error ? error : new Error(String(error)));
37
+ });
38
+ });
39
+ };
40
+ const handleError = (payload) => {
41
+ const message = typeof payload === "object" &&
42
+ payload !== null &&
43
+ "description" in payload &&
44
+ typeof payload.description === "string"
45
+ ? payload.description
46
+ : `Storybook failed to render story ${currentStoryId}`;
47
+ settle(() => {
48
+ reject(new Error(message));
49
+ });
50
+ };
51
+ const timeout = setTimeout(() => {
52
+ settle(() => {
53
+ reject(new Error(`Failed to select story '${currentStoryId}': Story switch timeout`));
54
+ });
55
+ }, storySwitchTimeoutMs);
56
+ channel.on("storyRendered", handleSuccess);
57
+ channel.on("storyUnchanged", handleSuccess);
58
+ channel.on("storyErrored", handleError);
59
+ channel.emit("setCurrentStory", { storyId: currentStoryId });
60
+ });
61
+ }, storyId);
62
+ },
63
+ async updateGlobals(page, globals) {
64
+ await page.evaluate((nextGlobals) => {
65
+ const channel = window.__STORYBOOK_ADDONS_CHANNEL__;
66
+ if (!channel) {
67
+ throw new Error("Storybook addons channel is unavailable");
68
+ }
69
+ channel.emit("updateGlobals", { globals: nextGlobals });
70
+ }, globals);
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,5 @@
1
+ import type { Page } from "@playwright/test";
2
+ export interface StorybookDriver {
3
+ selectStory(page: Page, storyId: string): Promise<void>;
4
+ updateGlobals(page: Page, globals: Record<string, unknown>): Promise<void>;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "@crvy/strybk",
3
+ "version": "0.0.1",
4
+ "description": "Generator-first Playwright screenshot testing for Storybook.",
5
+ "keywords": [
6
+ "crvy",
7
+ "playwright",
8
+ "screenshot",
9
+ "storybook",
10
+ "testing",
11
+ "visual-regression"
12
+ ],
13
+ "homepage": "https://github.com/creevey/strybk",
14
+ "bugs": {
15
+ "url": "https://github.com/creevey/strybk/issues"
16
+ },
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/creevey/strybk.git"
21
+ },
22
+ "bin": {
23
+ "strybk": "./dist/src/cli.js"
24
+ },
25
+ "files": [
26
+ "dist/",
27
+ "LICENSE",
28
+ "README.md",
29
+ "CHANGELOG.md"
30
+ ],
31
+ "type": "module",
32
+ "main": "./dist/src/index.js",
33
+ "types": "./dist/src/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/src/index.d.ts",
37
+ "default": "./dist/src/index.js"
38
+ },
39
+ "./config": {
40
+ "types": "./dist/src/config.d.ts",
41
+ "default": "./dist/src/config.js"
42
+ },
43
+ "./playwright": {
44
+ "types": "./dist/src/playwright/index.d.ts",
45
+ "default": "./dist/src/playwright/index.js"
46
+ }
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
53
+ "lint": "oxlint --config .oxlintrc.json .",
54
+ "lint:fix": "oxlint --config .oxlintrc.json --fix .",
55
+ "format": "oxfmt --write . --ignore-path=.oxfmtignore",
56
+ "format:check": "oxfmt --check . --ignore-path=.oxfmtignore",
57
+ "typecheck": "tsc -p tsconfig.json --noEmit",
58
+ "knip": "knip-bun --strict",
59
+ "duplicates": "jscpd --config .jscpd.json",
60
+ "test": "bun run test:bun",
61
+ "test:bun": "for file in tests/*.test.ts; do bun test \"$file\" || exit $?; done",
62
+ "check": "bash ./scripts/check.sh",
63
+ "check:staged": "bash ./scripts/check.sh --staged",
64
+ "changelog:preview": "sh ./scripts/changelog-release.sh --preview",
65
+ "changelog:generate": "sh ./scripts/changelog-release.sh --generate",
66
+ "changelog:release": "sh ./scripts/changelog-release.sh",
67
+ "prepublishOnly": "bun run build && bunx publint",
68
+ "prepare": "[ -d .git ] && cp scripts/pre-commit.sh .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit || true"
69
+ },
70
+ "dependencies": {
71
+ "glob": "^11.0.3"
72
+ },
73
+ "devDependencies": {
74
+ "@playwright/test": "1.59.1",
75
+ "@types/bun": "^1.3.7",
76
+ "@types/node": "25.9.0",
77
+ "git-cliff": "2.13.1",
78
+ "jscpd": "^4.0.8",
79
+ "knip": "^6.3.0",
80
+ "oxfmt": "^0.41.0",
81
+ "oxlint": "^1.56.0",
82
+ "oxlint-tsgolint": "^0.22.1",
83
+ "publint": "^0.3.18",
84
+ "typescript": "5.9.3",
85
+ "vitest": "^3.2.4"
86
+ },
87
+ "peerDependencies": {
88
+ "@playwright/test": ">=1.59.0"
89
+ },
90
+ "engines": {
91
+ "bun": ">=1.3.0",
92
+ "node": ">=24"
93
+ },
94
+ "packageManager": "bun@1.3.13"
95
+ }