@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 +8 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/src/cli.d.ts +12 -0
- package/dist/src/cli.js +174 -0
- package/dist/src/config.d.ts +20 -0
- package/dist/src/config.js +8 -0
- package/dist/src/generate/discover.d.ts +5 -0
- package/dist/src/generate/discover.js +13 -0
- package/dist/src/generate/index.d.ts +16 -0
- package/dist/src/generate/index.js +53 -0
- package/dist/src/generate/metadata.d.ts +5 -0
- package/dist/src/generate/metadata.js +124 -0
- package/dist/src/generate/render.d.ts +13 -0
- package/dist/src/generate/render.js +8 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +3 -0
- package/dist/src/playwright/fixtures.d.ts +11 -0
- package/dist/src/playwright/fixtures.js +84 -0
- package/dist/src/playwright/index.d.ts +2 -0
- package/dist/src/playwright/index.js +2 -0
- package/dist/src/playwright/runtime.d.ts +1 -0
- package/dist/src/playwright/runtime.js +16 -0
- package/dist/src/playwright/switchStory.d.ts +2 -0
- package/dist/src/playwright/switchStory.js +5 -0
- package/dist/src/storybook/channelDriver.d.ts +2 -0
- package/dist/src/storybook/channelDriver.js +73 -0
- package/dist/src/storybook/driver.d.ts +5 -0
- package/dist/src/storybook/driver.js +1 -0
- package/package.json +95 -0
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>;
|
package/dist/src/cli.js
ADDED
|
@@ -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,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,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,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 @@
|
|
|
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,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 @@
|
|
|
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
|
+
}
|