diff --git a/package.json b/package.json index aed02f68..6ab08079 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,56 @@ { + "name": "@chromatic-com/storybook", + "version": "1.3.2", + "description": "Catch unexpected visual changes & UI bugs in your stories", + "keywords": [ + "storybook-addons", + "test", + "visual tests", + "vrt", + "chromatic" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/chromaui/addon-visual-tests.git" + }, + "license": "MIT", "author": "Chromatic ", - "bundler": { - "exportEntries": [ - "src/index.ts" - ], - "managerEntries": [ - "src/manager.tsx" - ] + "exports": { + ".": { + "types": "./dist/index.d.ts", + "node": "./dist/index.js", + "require": "./dist/index.js", + "import": "./dist/index.mjs" + }, + "./manager": "./dist/manager.js", + "./preset": "./dist/preset.js", + "./package.json": "./package.json" + }, + "main": "dist/index.js", + "files": [ + "dist/**/*", + "README.md", + "*.js", + "*.d.ts" + ], + "scripts": { + "build": "tsup", + "build-storybook": "storybook build", + "build:staging": "CHROMATIC_BASE_URL=https://www.staging-chromatic.com tsup", + "build:watch": "run-p 'build --watch' 'codegen --watch'", + "chromatic": "chromatic --config-file production.config.json", + "codegen": "graphql-codegen", + "lint": "eslint src --max-warnings 0 --report-unused-disable-directives", + "prerelease": "zx scripts/prepublish-checks.mjs", + "release": "yarn run build && auto shipit", + "start": "run-p build:watch 'storybook --quiet'", + "storybook": "CHROMATIC_ADDON_NAME='../src/dev.ts' storybook dev -p 6006", + "storybook-from-dist": "CHROMATIC_USE_DIST_VERSION=true CHROMATIC_ADDON_NAME='../dist/index.js' storybook dev -p 6006", + "test": "vitest", + "typecheck": "tsc --noemit" + }, + "resolutions": { + "jackspeak": "2.1.1" }, "dependencies": { "chromatic": "^11.3.0", @@ -15,7 +59,6 @@ "react-confetti": "^6.1.0", "strip-ansi": "^7.1.0" }, - "description": "Catch unexpected visual changes & UI bugs in your stories", "devDependencies": { "@emotion/weak-memoize": "^0.3.1", "@graphql-codegen/cli": "^4.0.1", @@ -90,60 +133,22 @@ "node": ">=16.0.0", "yarn": ">=1.22.18" }, - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.js" - }, - "./manager": { - "import": "./dist/manager.mjs" - }, - "./package.json": "./package.json" - }, - "files": [ - "dist/**/*", - "README.md", - "*.js", - "*.d.ts" - ], - "keywords": [ - "storybook-addons", - "test", - "visual tests", - "vrt", - "chromatic" - ], - "license": "MIT", - "main": "dist/index.js", - "msw": { - "workerDirectory": "public" - }, - "name": "@chromatic-com/storybook", "publishConfig": { "access": "public" }, - "repository": { - "type": "git", - "url": "git+https://github.com/chromaui/addon-visual-tests.git" - }, - "resolutions": { - "jackspeak": "2.1.1" + "bundler": { + "exportEntries": [ + "./src/index.ts" + ], + "managerEntries": [ + "./src/manager.tsx" + ], + "nodeEntries": [ + "./src/preset.ts" + ] }, - "scripts": { - "build": "tsup", - "build-storybook": "storybook build", - "build:staging": "CHROMATIC_BASE_URL=https://www.staging-chromatic.com tsup", - "build:watch": "run-p 'build --watch' 'codegen --watch'", - "chromatic": "chromatic --config-file production.config.json", - "codegen": "graphql-codegen", - "lint": "eslint src --max-warnings 0 --report-unused-disable-directives", - "prerelease": "zx scripts/prepublish-checks.mjs", - "release": "yarn run build && auto shipit", - "start": "run-p build:watch 'storybook --quiet'", - "storybook": "CHROMATIC_ADDON_NAME='../src/dev.ts' storybook dev -p 6006", - "storybook-from-dist": "CHROMATIC_USE_DIST_VERSION=true CHROMATIC_ADDON_NAME='../dist/index.js' storybook dev -p 6006", - "test": "vitest", - "typecheck": "tsc --noemit" + "msw": { + "workerDirectory": "public" }, "storybook": { "displayName": "Visual Tests", @@ -159,6 +164,5 @@ "preact", "react-native" ] - }, - "version": "1.3.2" + } } diff --git a/preset.js b/preset.js new file mode 100644 index 00000000..fd0b2e4d --- /dev/null +++ b/preset.js @@ -0,0 +1 @@ +module.exports = require("./dist/preset.js"); diff --git a/src/index.ts b/src/index.ts index d884ae22..ff8b4c56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,264 +1 @@ -/* eslint-disable no-console */ -import { watch } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { normalize, relative } from "node:path"; - -import type { Channel } from "@storybook/channels"; -import { telemetry } from "@storybook/telemetry"; -import type { Options } from "@storybook/types"; -// eslint-disable-next-line import/no-unresolved -import { type Configuration, getConfiguration, getGitInfo, type GitInfo } from "chromatic/node"; - -import { - ADDON_ID, - CHROMATIC_BASE_URL, - CONFIG_INFO, - GIT_INFO, - GIT_INFO_ERROR, - LOCAL_BUILD_PROGRESS, - PACKAGE_NAME, - PROJECT_INFO, - REMOVE_ADDON, - START_BUILD, - STOP_BUILD, - TELEMETRY, -} from "./constants"; -import { runChromaticBuild, stopChromaticBuild } from "./runChromaticBuild"; -import { - ConfigInfoPayload, - ConfigurationUpdate, - GitInfoPayload, - LocalBuildProgress, - ProjectInfoPayload, -} from "./types"; -import { SharedState } from "./utils/SharedState"; -import { updateChromaticConfig } from "./utils/updateChromaticConfig"; - -/** - * to load the built addon in this test Storybook - */ -function managerEntries(entry: string[] = []) { - return [...entry, require.resolve("./manager.mjs")]; -} - -// Load the addon version from the package.json file, once. -let getAddonVersion = async (): Promise => { - const promise = (async () => { - try { - const packageJsonPath = require.resolve("@chromatic-com/storybook/package.json"); - const packageJsonData = await readFile(packageJsonPath, "utf-8"); - return JSON.parse(packageJsonData).version || null; - } catch (e) { - return null; - } - })(); - getAddonVersion = () => promise; - return promise; -}; - -// Nullify any suggestions that are the same as the defaults, to suggest removal. -// Drop suggestions for removal that don't actually appear in the current config. -const suggestRemovals = ( - config: Configuration, - defaults: Configuration, - update: ConfigurationUpdate -) => - Object.fromEntries( - (Object.entries(update) as [keyof Configuration, Configuration[keyof Configuration]][]) - .map(([key, value]) => [key, value === defaults[key] ? null : value]) - .filter(([key, value]) => value !== null || config[key as keyof Configuration] !== undefined) - ); - -// Detect problems in the current configuration and suggest updates. -const getConfigInfo = async ( - configuration: Awaited>, - options: Options -) => { - const defaults: Configuration = { - storybookBaseDir: ".", - storybookConfigDir: ".storybook", - } as const; - - const problems: ConfigurationUpdate = {}; - const suggestions: ConfigurationUpdate = {}; - - const { repositoryRootDir } = await getGitInfo(); - const baseDir = repositoryRootDir && normalize(relative(repositoryRootDir, process.cwd())); - if (baseDir !== normalize(configuration.storybookBaseDir ?? "")) { - problems.storybookBaseDir = baseDir; - } - - const configDir = normalize(relative(process.cwd(), options.configDir)); - if (configDir !== normalize(configuration.storybookConfigDir ?? "")) { - problems.storybookConfigDir = configDir; - } - - if (!configuration.zip) { - suggestions.zip = true; - } - - return { - configuration, - problems: suggestRemovals(configuration, defaults, problems), - suggestions: suggestRemovals(configuration, defaults, suggestions), - }; -}; - -// Polls for changes to the Git state and invokes the callback when it changes. -// Uses a recursive setTimeout instead of setInterval to avoid overlapping async calls. -const observeGitInfo = async ( - interval: number, - callback: (info: GitInfo, prevInfo?: GitInfo) => void, - errorCallback: (e: Error) => void, - projectId?: string -) => { - let prev: GitInfo | undefined; - let prevError: Error | undefined; - let timer: NodeJS.Timeout | undefined; - const act = async () => { - try { - const gitInfo = await getGitInfo(); - if (Object.entries(gitInfo).some(([key, value]) => prev?.[key as keyof GitInfo] !== value)) { - callback(gitInfo, prev); - } - prev = gitInfo; - prevError = undefined; - timer = setTimeout(act, interval); - } catch (e: any) { - errorCallback(e); - if (projectId && prevError?.message !== e.message) { - console.error(`Failed to fetch git info, with error:\n${e}`); - prev = undefined; - prevError = e; - } - timer = setTimeout(act, interval); - } - }; - act(); - - return () => clearTimeout(timer); -}; - -const watchConfigFile = async ( - configFile: string | undefined, - onChange: (configuration: Awaited>) => Promise -) => { - const configuration = await getConfiguration(configFile); - await onChange(configuration); - - if (configuration.configFile) { - watch(configuration.configFile, async (eventType: string, filename: string | null) => { - if (filename) await onChange(await getConfiguration(filename)); - }); - } -}; - -async function serverChannel(channel: Channel, options: Options & { configFile?: string }) { - const { configFile, presets } = options; - - // Lazy load these APIs since we don't need them right away - const apiPromise = presets.apply("experimental_serverAPI"); - const corePromise = presets.apply("core"); - - // This yields an empty object if the file doesn't exist and no explicit configFile is specified - const { projectId: initialProjectId } = await getConfiguration(configFile); - - const projectInfoState = SharedState.subscribe(PROJECT_INFO, channel); - projectInfoState.value = initialProjectId ? { projectId: initialProjectId } : {}; - - let lastProjectId = initialProjectId; - projectInfoState.on("change", async ({ projectId } = {}) => { - if (!projectId || projectId === lastProjectId) return; - lastProjectId = projectId; - - const writtenConfigFile = configFile; - try { - // No config file may be found (file is about to be created) - const { configFile: foundConfigFile, ...config } = await getConfiguration(writtenConfigFile); - const targetConfigFile = foundConfigFile || writtenConfigFile || "chromatic.config.json"; - await updateChromaticConfig(targetConfigFile, { ...config, projectId, zip: true }); - - projectInfoState.value = { - ...projectInfoState.value, - written: true, - dismissed: false, - configFile: targetConfigFile, - }; - } catch (err) { - console.warn(`Failed to update your main configuration:\n\n ${err}`); - - projectInfoState.value = { - ...projectInfoState.value, - written: false, - dismissed: false, - configFile: writtenConfigFile, - }; - } - }); - - const localBuildProgress = SharedState.subscribe( - LOCAL_BUILD_PROGRESS, - channel - ); - - channel.on(START_BUILD, async ({ accessToken: userToken }) => { - const { projectId } = projectInfoState.value || {}; - try { - await runChromaticBuild(localBuildProgress, { configFile, projectId, userToken }); - } catch (e) { - console.error(`Failed to run Chromatic build, with error:\n${e}`); - } - }); - - channel.on(STOP_BUILD, stopChromaticBuild); - channel.on(REMOVE_ADDON, () => - apiPromise.then((api) => api.removeAddon(PACKAGE_NAME)).catch((e) => console.error(e)) - ); - - channel.on(TELEMETRY, async (event: Event) => { - if ((await corePromise).disableTelemetry) return; - telemetry("addon-visual-tests" as any, { ...event, addonVersion: await getAddonVersion() }); - }); - - const configInfoState = SharedState.subscribe(CONFIG_INFO, channel); - const gitInfoState = SharedState.subscribe(GIT_INFO, channel); - const gitInfoError = SharedState.subscribe(GIT_INFO_ERROR, channel); - - observeGitInfo( - 5000, - (info) => { - gitInfoError.value = undefined; - gitInfoState.value = info; - }, - (error: Error) => { - gitInfoError.value = error; - } - ); - - watchConfigFile(configFile, async (configuration) => { - if (!lastProjectId) return; - configInfoState.value = await getConfigInfo(configuration, options); - }); - - setInterval(() => channel.emit(`${ADDON_ID}/heartbeat`), 1000); - - return channel; -} - -const config = { - managerEntries, - experimental_serverChannel: serverChannel, - env: async ( - env: Record, - { configType }: { configType: "DEVELOPMENT" | "PRODUCTION" } - ) => { - if (configType === "PRODUCTION") return env; - - return { - ...env, - CHROMATIC_BASE_URL, - }; - }, -}; - -export default config; +export default {}; diff --git a/src/preset.ts b/src/preset.ts new file mode 100644 index 00000000..d884ae22 --- /dev/null +++ b/src/preset.ts @@ -0,0 +1,264 @@ +/* eslint-disable no-console */ +import { watch } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { normalize, relative } from "node:path"; + +import type { Channel } from "@storybook/channels"; +import { telemetry } from "@storybook/telemetry"; +import type { Options } from "@storybook/types"; +// eslint-disable-next-line import/no-unresolved +import { type Configuration, getConfiguration, getGitInfo, type GitInfo } from "chromatic/node"; + +import { + ADDON_ID, + CHROMATIC_BASE_URL, + CONFIG_INFO, + GIT_INFO, + GIT_INFO_ERROR, + LOCAL_BUILD_PROGRESS, + PACKAGE_NAME, + PROJECT_INFO, + REMOVE_ADDON, + START_BUILD, + STOP_BUILD, + TELEMETRY, +} from "./constants"; +import { runChromaticBuild, stopChromaticBuild } from "./runChromaticBuild"; +import { + ConfigInfoPayload, + ConfigurationUpdate, + GitInfoPayload, + LocalBuildProgress, + ProjectInfoPayload, +} from "./types"; +import { SharedState } from "./utils/SharedState"; +import { updateChromaticConfig } from "./utils/updateChromaticConfig"; + +/** + * to load the built addon in this test Storybook + */ +function managerEntries(entry: string[] = []) { + return [...entry, require.resolve("./manager.mjs")]; +} + +// Load the addon version from the package.json file, once. +let getAddonVersion = async (): Promise => { + const promise = (async () => { + try { + const packageJsonPath = require.resolve("@chromatic-com/storybook/package.json"); + const packageJsonData = await readFile(packageJsonPath, "utf-8"); + return JSON.parse(packageJsonData).version || null; + } catch (e) { + return null; + } + })(); + getAddonVersion = () => promise; + return promise; +}; + +// Nullify any suggestions that are the same as the defaults, to suggest removal. +// Drop suggestions for removal that don't actually appear in the current config. +const suggestRemovals = ( + config: Configuration, + defaults: Configuration, + update: ConfigurationUpdate +) => + Object.fromEntries( + (Object.entries(update) as [keyof Configuration, Configuration[keyof Configuration]][]) + .map(([key, value]) => [key, value === defaults[key] ? null : value]) + .filter(([key, value]) => value !== null || config[key as keyof Configuration] !== undefined) + ); + +// Detect problems in the current configuration and suggest updates. +const getConfigInfo = async ( + configuration: Awaited>, + options: Options +) => { + const defaults: Configuration = { + storybookBaseDir: ".", + storybookConfigDir: ".storybook", + } as const; + + const problems: ConfigurationUpdate = {}; + const suggestions: ConfigurationUpdate = {}; + + const { repositoryRootDir } = await getGitInfo(); + const baseDir = repositoryRootDir && normalize(relative(repositoryRootDir, process.cwd())); + if (baseDir !== normalize(configuration.storybookBaseDir ?? "")) { + problems.storybookBaseDir = baseDir; + } + + const configDir = normalize(relative(process.cwd(), options.configDir)); + if (configDir !== normalize(configuration.storybookConfigDir ?? "")) { + problems.storybookConfigDir = configDir; + } + + if (!configuration.zip) { + suggestions.zip = true; + } + + return { + configuration, + problems: suggestRemovals(configuration, defaults, problems), + suggestions: suggestRemovals(configuration, defaults, suggestions), + }; +}; + +// Polls for changes to the Git state and invokes the callback when it changes. +// Uses a recursive setTimeout instead of setInterval to avoid overlapping async calls. +const observeGitInfo = async ( + interval: number, + callback: (info: GitInfo, prevInfo?: GitInfo) => void, + errorCallback: (e: Error) => void, + projectId?: string +) => { + let prev: GitInfo | undefined; + let prevError: Error | undefined; + let timer: NodeJS.Timeout | undefined; + const act = async () => { + try { + const gitInfo = await getGitInfo(); + if (Object.entries(gitInfo).some(([key, value]) => prev?.[key as keyof GitInfo] !== value)) { + callback(gitInfo, prev); + } + prev = gitInfo; + prevError = undefined; + timer = setTimeout(act, interval); + } catch (e: any) { + errorCallback(e); + if (projectId && prevError?.message !== e.message) { + console.error(`Failed to fetch git info, with error:\n${e}`); + prev = undefined; + prevError = e; + } + timer = setTimeout(act, interval); + } + }; + act(); + + return () => clearTimeout(timer); +}; + +const watchConfigFile = async ( + configFile: string | undefined, + onChange: (configuration: Awaited>) => Promise +) => { + const configuration = await getConfiguration(configFile); + await onChange(configuration); + + if (configuration.configFile) { + watch(configuration.configFile, async (eventType: string, filename: string | null) => { + if (filename) await onChange(await getConfiguration(filename)); + }); + } +}; + +async function serverChannel(channel: Channel, options: Options & { configFile?: string }) { + const { configFile, presets } = options; + + // Lazy load these APIs since we don't need them right away + const apiPromise = presets.apply("experimental_serverAPI"); + const corePromise = presets.apply("core"); + + // This yields an empty object if the file doesn't exist and no explicit configFile is specified + const { projectId: initialProjectId } = await getConfiguration(configFile); + + const projectInfoState = SharedState.subscribe(PROJECT_INFO, channel); + projectInfoState.value = initialProjectId ? { projectId: initialProjectId } : {}; + + let lastProjectId = initialProjectId; + projectInfoState.on("change", async ({ projectId } = {}) => { + if (!projectId || projectId === lastProjectId) return; + lastProjectId = projectId; + + const writtenConfigFile = configFile; + try { + // No config file may be found (file is about to be created) + const { configFile: foundConfigFile, ...config } = await getConfiguration(writtenConfigFile); + const targetConfigFile = foundConfigFile || writtenConfigFile || "chromatic.config.json"; + await updateChromaticConfig(targetConfigFile, { ...config, projectId, zip: true }); + + projectInfoState.value = { + ...projectInfoState.value, + written: true, + dismissed: false, + configFile: targetConfigFile, + }; + } catch (err) { + console.warn(`Failed to update your main configuration:\n\n ${err}`); + + projectInfoState.value = { + ...projectInfoState.value, + written: false, + dismissed: false, + configFile: writtenConfigFile, + }; + } + }); + + const localBuildProgress = SharedState.subscribe( + LOCAL_BUILD_PROGRESS, + channel + ); + + channel.on(START_BUILD, async ({ accessToken: userToken }) => { + const { projectId } = projectInfoState.value || {}; + try { + await runChromaticBuild(localBuildProgress, { configFile, projectId, userToken }); + } catch (e) { + console.error(`Failed to run Chromatic build, with error:\n${e}`); + } + }); + + channel.on(STOP_BUILD, stopChromaticBuild); + channel.on(REMOVE_ADDON, () => + apiPromise.then((api) => api.removeAddon(PACKAGE_NAME)).catch((e) => console.error(e)) + ); + + channel.on(TELEMETRY, async (event: Event) => { + if ((await corePromise).disableTelemetry) return; + telemetry("addon-visual-tests" as any, { ...event, addonVersion: await getAddonVersion() }); + }); + + const configInfoState = SharedState.subscribe(CONFIG_INFO, channel); + const gitInfoState = SharedState.subscribe(GIT_INFO, channel); + const gitInfoError = SharedState.subscribe(GIT_INFO_ERROR, channel); + + observeGitInfo( + 5000, + (info) => { + gitInfoError.value = undefined; + gitInfoState.value = info; + }, + (error: Error) => { + gitInfoError.value = error; + } + ); + + watchConfigFile(configFile, async (configuration) => { + if (!lastProjectId) return; + configInfoState.value = await getConfigInfo(configuration, options); + }); + + setInterval(() => channel.emit(`${ADDON_ID}/heartbeat`), 1000); + + return channel; +} + +const config = { + managerEntries, + experimental_serverChannel: serverChannel, + env: async ( + env: Record, + { configType }: { configType: "DEVELOPMENT" | "PRODUCTION" } + ) => { + if (configType === "PRODUCTION") return env; + + return { + ...env, + CHROMATIC_BASE_URL, + }; + }, +}; + +export default config; diff --git a/tsup.config.ts b/tsup.config.ts index eac8448b..eef05c55 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,15 +1,21 @@ import { defineConfig, type Options } from "tsup"; import { readFile } from "fs/promises"; import { globalPackages as globalManagerPackages } from "@storybook/manager/globals"; -import { globalPackages as globalPreviewPackages } from "@storybook/preview/globals"; +import type { PackageJson } from "type-fest"; +type Formats = "esm" | "cjs"; type BundlerConfig = { - bundler?: { - exportEntries?: string[]; - nodeEntries?: string[]; - managerEntries?: string[]; - previewEntries?: string[]; - }; + previewEntries: string[]; + managerEntries: string[]; + nodeEntries: string[]; + exportEntries: string[]; + externals: string[]; + pre: string; + post: string; + formats: Formats[]; +}; +type PackageJsonWithBundlerConfig = PackageJson & { + bundler: BundlerConfig; }; export default defineConfig(async (options) => { @@ -17,41 +23,59 @@ export default defineConfig(async (options) => { // { // ... // "bundler": { - // "exportEntries": ["./src/index.ts"], + // "nodeEntries": ["./src/index.ts"], // "managerEntries": ["./src/manager.tsx"], // } // } - const packageJson = (await readFile("./package.json", "utf8").then(JSON.parse)) as BundlerConfig; - const { bundler: { exportEntries = [], managerEntries = [], previewEntries = [] } = {} } = - packageJson; + const packageJson = (await readFile("./package.json", "utf8").then( + JSON.parse + )) as PackageJsonWithBundlerConfig; + const { + name, + dependencies, + peerDependencies, + bundler: { + exportEntries = [], + nodeEntries = [], + managerEntries = [], + externals: extraExternals = [], + } = {}, + } = packageJson; const commonConfig: Options = { splitting: false, minify: !options.watch, treeshake: true, - sourcemap: true, clean: true, }; + const browserOptions: Options = { + target: ["chrome100", "safari15", "firefox91"], + platform: "browser", + format: ["esm"], + }; + + const commonExternals = [ + name, + ...extraExternals, + ...Object.keys(dependencies || {}), + ...Object.keys(peerDependencies || {}), + ] as string[]; + const configs: Options[] = []; const globalManagerPackagesNoIcons = globalManagerPackages.filter( (packageJson) => packageJson !== "@storybook/icons" ); - // export entries are entries meant to be manually imported by the user - // they are not meant to be loaded by the manager or preview - // they'll be usable in both node and browser environments, depending on which features and modules they depend on - if (exportEntries.length) { + if (nodeEntries.length) { configs.push({ ...commonConfig, - entry: exportEntries, - dts: { - resolve: true, - }, - format: ["esm", "cjs"], + entry: nodeEntries, + format: ["cjs"], + target: "node18", platform: "node", - external: [...globalManagerPackagesNoIcons, ...globalPreviewPackages], + external: commonExternals, }); } @@ -75,6 +99,21 @@ export default defineConfig(async (options) => { }); } + if (exportEntries.length > 0) { + configs.push({ + ...commonConfig, + ...browserOptions, + entry: exportEntries, + }); + configs.push({ + ...commonConfig, + entry: exportEntries, + format: ["cjs"], + target: browserOptions.target, + platform: "neutral", + }); + } + // This addon doesn't use preview entries but this is the recommended way to do it if we ever do // preview entries are entries meant to be loaded into the preview iframe