msw-storybook-addon
Version:
Mock APIs in Storybook using Mock Service Worker.
1,080 lines (1,074 loc) • 45.2 kB
JavaScript
import { existsSync, promises } from "node:fs";
import os from "node:os";
import { join, relative, resolve, sep } from "node:path";
import { formatFileContent, globToRegexp } from "storybook/internal/common";
import { CLI_COLORS, logger } from "storybook/internal/node-logger";
import { recast, types } from "storybook/internal/babel";
import { babelParse, isCsfFactoryPreview, loadConfig, loadCsf, printConfig, printCsf } from "storybook/internal/csf-tools";
//#region node_modules/.pnpm/ts-dedent@2.3.0/node_modules/ts-dedent/esm/index.js
function dedent(templ) {
var values = [];
for (var _i = 1; _i < arguments.length; _i++) values[_i - 1] = arguments[_i];
var strings = Array.from(typeof templ === "string" ? [templ] : templ);
strings[strings.length - 1] = strings[strings.length - 1].replace(/\r?\n([\t ]*)$/, "");
var indentLengths = strings.reduce(function(arr, str) {
var matches = str.match(/\n([\t ]+|(?!\s).)/g);
if (matches) return arr.concat(matches.map(function(match) {
var _a, _b;
return (_b = (_a = match.match(/[\t ]/g)) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
}));
return arr;
}, []);
if (indentLengths.length) {
var pattern_1 = new RegExp("\n[ ]{".concat(Math.min.apply(Math, indentLengths), "}"), "g");
strings = strings.map(function(str) {
return str.replace(pattern_1, "\n");
});
}
strings[0] = strings[0].replace(/^\r?\n/, "");
var string = strings[0];
values.forEach(function(value, i) {
var endentations = string.match(/(?:^|\n)( *)$/);
var endentation = endentations ? endentations[1] : "";
var indentedValue = value;
if (typeof value === "string" && value.includes("\n")) indentedValue = String(value).split("\n").map(function(str, i) {
return i === 0 ? str : "".concat(endentation).concat(str);
}).join("\n");
string += indentedValue + strings[i + 1];
});
return string;
}
//#endregion
//#region codemod/src/transforms/stories.ts
/**
* Story-file transform.
* Migrates `parameters.msw.handlers` (and its legacy shapes) into a
* `beforeEach({ msw }) { msw.use(...) }` annotation. Idempotent: a file
* that no longer carries `parameters.msw` is left untouched.
* Only shapes the transform can prove safe are rewritten:
* - `RequestHandler[]` (legacy array form)
* - `{ handlers: RequestHandler[] }`
* - `{ handlers: Record<string, RequestHandler | RequestHandler[]> }`
* Anything else (custom keys, dynamic values, etc.) is skipped while
* the user gets a warning to migrate by hand.
* CSF3 (story-as-object) and CSF2 (post-export `Story.parameters = ...` annotation) are both handled.
*/
function transformStory(source) {
const skipped = [];
let parsed;
try {
parsed = loadCsf(source, { makeTitle: (title) => title || "default" }).parse();
} catch {
return {
code: null,
skippedStories: []
};
}
let changed = false;
for (const [name, decl] of Object.entries(parsed._storyExports)) {
const obj = getStoryObject(decl);
if (!obj) continue;
const result = migrateMswOnObject(obj, name);
if (result === "changed") changed = true;
else if (result !== "noop") skipped.push({
story: name,
reason: result
});
}
const metaNode = parsed._metaPath?.node ?? (parsed._metaVariableName ? findVariableDeclarator(parsed, parsed._metaVariableName) : void 0);
if (metaNode) {
const meta = resolveStoryObject(parsed, metaNode);
if (meta) {
const result = migrateMswOnObject(meta, "meta");
if (result === "changed") changed = true;
else if (result !== "noop") skipped.push({
story: "meta",
reason: result
});
}
}
for (let i = parsed._ast.program.body.length - 1; i >= 0; i--) {
const stmt = parsed._ast.program.body[i];
if (!isCsf2Annotation(stmt, parsed._storyExports)) continue;
const assign = stmt.expression;
const left = assign.left;
const storyName = left.object.name;
const propName = types.isIdentifier(left.property) && left.property.name || types.isStringLiteral(left.property) && left.property.value || null;
if (propName !== "parameters" && propName !== "story") continue;
if (!types.isObjectExpression(assign.right)) continue;
const result = migrateCsf2Annotation(parsed, stmt, storyName, i, propName);
if (result === "changed") changed = true;
else if (result !== "noop") skipped.push({
story: storyName,
reason: result
});
}
if (!changed) return {
code: null,
skippedStories: skipped
};
return {
code: printCsf(parsed).code,
skippedStories: skipped
};
}
function migrateMswOnObject(obj, storyName) {
const parametersProp = findObjectProperty(obj, "parameters");
if (!parametersProp || !types.isObjectExpression(parametersProp.value)) return "noop";
const parameters = parametersProp.value;
const mswProp = findObjectProperty(parameters, "msw");
if (!mswProp) return "noop";
const extracted = extractHandlers(mswProp.value);
if (!extracted) return "unrecognized-shape";
if (extracted.length > 0 && hasAnyProperty(obj, "beforeEach")) return "existing-before-each";
if (extracted.length > 0) {
const beforeEachProp = buildBeforeEachProperty(extracted);
const insertIdx = obj.properties.indexOf(parametersProp);
obj.properties.splice(insertIdx, 0, beforeEachProp);
}
parameters.properties = parameters.properties.filter((p) => p !== mswProp);
if (parameters.properties.length === 0) obj.properties = obj.properties.filter((p) => p !== parametersProp);
return "changed";
}
function extractHandlers(value) {
if (types.isArrayExpression(value)) return collectArrayElements(value);
if (types.isObjectExpression(value)) {
const handlersProp = findObjectProperty(value, "handlers");
if (!handlersProp) return null;
const hv = handlersProp.value;
if (types.isArrayExpression(hv)) return collectArrayElements(hv);
if (types.isObjectExpression(hv)) {
const acc = [];
for (const prop of hv.properties) {
if (!types.isObjectProperty(prop)) return null;
if (types.isArrayExpression(prop.value)) {
const elems = collectArrayElements(prop.value);
if (!elems) return null;
acc.push(...elems);
} else if (isHandlerLikeExpression(prop.value)) acc.push(prop.value);
else return null;
}
return acc;
}
return null;
}
return null;
}
function collectArrayElements(arr) {
const acc = [];
for (const el of arr.elements) {
if (el === null) return null;
if (types.isSpreadElement(el)) {
acc.push(el);
continue;
}
if (!isHandlerLikeExpression(el)) return null;
acc.push(el);
}
return acc;
}
function isHandlerLikeExpression(node) {
return !types.isStringLiteral(node) && !types.isNumericLiteral(node) && !types.isBooleanLiteral(node);
}
function parseTemplate(template) {
return recast.parse(template, { parser: { parse: (source) => babelParse(source) } }).program.body[0].expression;
}
function buildBeforeEachProperty(handlers) {
const method = parseTemplate("({\n beforeEach({ msw }) {\n msw.use()\n }\n})").properties[0];
method.body.body[0].expression.arguments.push(...handlers);
return method;
}
function buildBeforeEachArrow(handlers) {
const arrow = parseTemplate("({\n beforeEach: ({ msw }) => {\n msw.use()\n }\n})").properties[0].value;
arrow.body.body[0].expression.arguments.push(...handlers);
return arrow;
}
function findObjectProperty(obj, name) {
for (const prop of obj.properties) {
if (!types.isObjectProperty(prop)) continue;
const key = prop.key;
if (types.isIdentifier(key) && key.name === name) return prop;
if (types.isStringLiteral(key) && key.value === name) return prop;
}
}
function hasAnyProperty(obj, name) {
for (const prop of obj.properties) if (types.isObjectProperty(prop) || types.isObjectMethod(prop)) {
const key = prop.key;
if (types.isIdentifier(key) && key.name === name) return true;
if (types.isStringLiteral(key) && key.value === name) return true;
}
return false;
}
function getStoryObject(node) {
if (types.isVariableDeclarator(node)) {
let init = node.init;
if (types.isTSSatisfiesExpression(init) || types.isTSAsExpression(init)) init = init.expression;
if (init && types.isObjectExpression(init)) return init;
if (init) return unwrapFactoryCall(init);
} else if (types.isExportDefaultDeclaration(node)) {
let init = node.declaration;
if (init && (types.isTSSatisfiesExpression(init) || types.isTSAsExpression(init))) init = init.expression;
if (init && types.isObjectExpression(init)) return init;
}
}
function unwrapFactoryCall(node) {
if (types.isCallExpression(node) && types.isMemberExpression(node.callee) && types.isIdentifier(node.callee.property) && (node.callee.property.name === "meta" || node.callee.property.name === "story") && node.arguments[0] != null && types.isObjectExpression(node.arguments[0])) return node.arguments[0];
}
function resolveStoryObject(parsed, node) {
const direct = getStoryObject(node);
if (direct) return direct;
if (types.isExportDefaultDeclaration(node)) {
let decl = node.declaration;
if (decl && (types.isTSSatisfiesExpression(decl) || types.isTSAsExpression(decl))) decl = decl.expression;
if (decl && types.isIdentifier(decl)) {
const ref = findVariableDeclarator(parsed, decl.name);
if (ref) return getStoryObject(ref);
}
}
}
function findVariableDeclarator(parsed, name) {
for (const stmt of parsed._ast.program.body) {
const decl = types.isExportNamedDeclaration(stmt) ? stmt.declaration : stmt;
if (!decl || !types.isVariableDeclaration(decl)) continue;
for (const d of decl.declarations) if (types.isIdentifier(d.id) && d.id.name === name) return d;
}
}
function migrateCsf2Annotation(parsed, stmt, storyName, stmtIndex, form) {
const container = stmt.expression.right;
let parametersObj;
let storyParametersProp;
if (form === "parameters") parametersObj = container;
else {
storyParametersProp = findObjectProperty(container, "parameters");
if (!storyParametersProp || !types.isObjectExpression(storyParametersProp.value)) return "noop";
parametersObj = storyParametersProp.value;
}
const mswProp = findObjectProperty(parametersObj, "msw");
if (!mswProp) return "noop";
if (parsed._storyAnnotations[storyName]?.beforeEach != null) return "existing-before-each";
const handlers = extractHandlers(mswProp.value);
if (!handlers) return "unrecognized-shape";
parametersObj.properties = parametersObj.properties.filter((p) => p !== mswProp);
if (storyParametersProp && parametersObj.properties.length === 0) container.properties = container.properties.filter((p) => p !== storyParametersProp);
const statementEmptied = container.properties.length === 0;
if (handlers.length === 0) {
if (statementEmptied) parsed._ast.program.body.splice(stmtIndex, 1);
return "changed";
}
const beforeEachAssign = types.expressionStatement(types.assignmentExpression("=", types.memberExpression(types.identifier(storyName), types.identifier("beforeEach")), buildBeforeEachArrow(handlers)));
if (statementEmptied) parsed._ast.program.body.splice(stmtIndex, 1, beforeEachAssign);
else parsed._ast.program.body.splice(stmtIndex + 1, 0, beforeEachAssign);
return "changed";
}
function isCsf2Annotation(stmt, storyExports) {
if (!types.isExpressionStatement(stmt)) return false;
const expr = stmt.expression;
if (!types.isAssignmentExpression(expr)) return false;
if (!types.isMemberExpression(expr.left)) return false;
if (!types.isIdentifier(expr.left.object)) return false;
return Boolean(storyExports[expr.left.object.name]);
}
//#endregion
//#region codemod/src/transforms/preview.ts
/**
* `.storybook/preview.{js,ts,jsx,tsx}` transform.
*
* Migrates a v2 preview to the v3 wiring:
*
* CSF 3.0 (plain-object preview):
* - `loaders: [mswLoader]` / `loaders: mswLoader` → `loaders: [mswLoader()]`
* - `decorators: [mswDecorator]` → removed; a `mswLoader()` loader is
* ensured instead (the 2.x migration documented them as equivalent).
* - `initialize()` (bare) → removed.
* - `initialize(options[, initialHandlers])` → removed; folded into a
* setup function passed to `mswLoader(async () => { ... })`.
* - `import { mswLoader } from 'msw-storybook-addon'` → retargeted to
* the `msw-storybook-addon/csf3` subpath.
*
* CSF Next (`definePreview({...})`):
* - Any leftover v2/v3 wiring (loaders, decorators, `initialize`) is
* stripped and `addonMsw()` is ensured in `addons: [...]`, with
* `initialize` options folded into `addonMsw(setup)`.
* - A file with no msw wiring at all is left untouched.
*
* Opt-in (`migrateParameters`): preview-level `parameters.msw` is
* converted into a `beforeEach({ msw })` hook using the same
* recognised-shapes-only rules as the story transform.
*
* Skip-and-warn (the file is left untouched and a warning is returned):
* - `const worker = initialize(...)` — the return value is captured;
* v3 has no synchronous worker reference to give back.
* - `initialize` referenced anywhere other than a plain top-level call
* (conditional calls, re-exports, passing it around).
* - More than one `initialize(...)` call.
* - `initialize(...args)` spread arguments.
* - `initialize` options to fold, but the existing `mswLoader(...)` /
* `addonMsw(...)` already carries a setup function.
*
* Idempotent: running twice produces the same result and no spurious diff.
*/
const ADDON_PACKAGE$1 = "msw-storybook-addon";
const CSF3_SUBPATH = "msw-storybook-addon/csf3";
const PREVIEW_SUBPATH = "msw-storybook-addon/preview";
const MSW_BROWSER = "msw/browser";
function transformPreview(source, options = {}) {
const warnings = [];
let parsed;
try {
parsed = loadConfig(source).parse();
} catch {
return {
code: null,
warnings
};
}
const body = parsed._ast.program.body;
const imports = collectAddonImports(body);
const previewObj = parsed._exportsObject ?? null;
const isFactories = isCsfFactoryPreview(parsed);
let fold = null;
if (imports.initialize) {
const analyzed = analyzeInitialize(parsed, imports.initialize, warnings);
if (analyzed === "skip") return {
code: null,
warnings,
csfNext: isFactories
};
fold = analyzed;
}
const usesMsw = imports.initialize != null || imports.mswLoader != null || imports.mswDecorator != null || imports.addonDefault != null;
if (!previewObj) {
if (usesMsw) warnings.push("Could not find the preview config object (the default export); you will need to migrate this file yourself.");
return {
code: null,
warnings,
csfNext: isFactories
};
}
const needsSetup = fold != null && (fold.options != null || fold.handlers != null);
let changed = false;
if (isFactories) {
const loaderSetup = imports.mswLoader ? collectLoaderSetup(previewObj, imports.mswLoader) : null;
if (loaderSetup === "conflict") {
warnings.push("Multiple `mswLoader(...)` setup functions found; you will need to migrate this file yourself.");
return {
code: null,
warnings,
csfNext: true
};
}
if (needsSetup && loaderSetup != null) {
warnings.push("Both `initialize(...)` options and a `mswLoader(...)` setup function exist; you will need to merge them into one setup function yourself.");
return {
code: null,
warnings,
csfNext: true
};
}
const strippedLoader = imports.mswLoader ? stripMswRef(previewObj, "loaders", imports.mswLoader) : false;
const strippedDecorator = imports.mswDecorator ? stripMswRef(previewObj, "decorators", imports.mswDecorator) : false;
const hasNamespaceEntry = imports.previewNamespace != null && findAddonsEntryIndex(previewObj, imports.previewNamespace) !== -1;
if (!(strippedLoader || strippedDecorator || hasNamespaceEntry || fold != null)) {
if (options.migrateParameters) {
if (migratePreviewParameters(previewObj, warnings)) changed = true;
}
return finish(parsed, source, changed, warnings, true);
}
const setupFn = needsSetup ? buildSetupFunction(fold) : loaderSetup;
if (ensureFactoriesAddon(parsed, previewObj, imports, setupFn, warnings) === "conflict") return {
code: null,
warnings,
csfNext: true
};
changed = true;
if (fold != null) removeStatement(body, fold.stmt);
removeSpecifiers(parsed, [
"initialize",
"mswLoader",
"mswDecorator"
]);
if (hasNamespaceEntry) removePreviewNamespaceImport(parsed);
if (needsSetup) ensureSetupWorkerImport(parsed);
} else {
if (!(imports.mswLoader != null || imports.mswDecorator != null || fold != null)) {
if (options.migrateParameters) {
if (migratePreviewParameters(previewObj, warnings)) changed = true;
}
return finish(parsed, source, changed, warnings, false);
}
const loaderName = imports.mswLoader ?? "mswLoader";
const loaderResult = ensureLoaderCall(previewObj, loaderName, needsSetup ? buildSetupFunction(fold) : null, imports);
if (loaderResult === "conflict") {
warnings.push("`initialize(...)` options could not be folded: the existing `mswLoader(...)` already receives a setup function. You will need to merge them yourself.");
return {
code: null,
warnings,
csfNext: false
};
}
if (loaderResult === "changed") changed = true;
if (imports.mswDecorator) {
if (stripMswRef(previewObj, "decorators", imports.mswDecorator)) changed = true;
}
if (fold != null) {
removeStatement(body, fold.stmt);
changed = true;
}
if (removeSpecifiers(parsed, ["initialize", "mswDecorator"])) changed = true;
if (retargetLoaderImport(parsed)) changed = true;
if (ensureLoaderImport(parsed, loaderName)) changed = true;
if (needsSetup) ensureSetupWorkerImport(parsed);
}
if (options.migrateParameters) {
if (migratePreviewParameters(previewObj, warnings)) changed = true;
}
return finish(parsed, source, changed, warnings, isFactories);
}
function finish(parsed, source, changed, warnings, csfNext) {
if (!changed) return {
code: null,
warnings,
csfNext
};
const code = printConfig(parsed).code;
return {
code: code === source ? null : code,
warnings,
csfNext
};
}
function collectAddonImports(body) {
const out = {};
for (const stmt of body) {
if (!types.isImportDeclaration(stmt)) continue;
const src = stmt.source.value;
if (src === PREVIEW_SUBPATH) {
for (const spec of stmt.specifiers) if (types.isImportNamespaceSpecifier(spec)) out.previewNamespace = spec.local.name;
continue;
}
if (src !== ADDON_PACKAGE$1 && src !== CSF3_SUBPATH) continue;
for (const spec of stmt.specifiers) if (types.isImportDefaultSpecifier(spec)) out.addonDefault = spec.local.name;
else if (types.isImportSpecifier(spec)) {
const name = importedName(spec);
if (name === "initialize") out.initialize = spec.local.name;
if (name === "mswLoader") out.mswLoader = spec.local.name;
if (name === "mswDecorator") out.mswDecorator = spec.local.name;
}
}
return out;
}
function importedName(spec) {
const imported = spec.imported;
return types.isIdentifier(imported) && imported.name || types.isStringLiteral(imported) && imported.value || null;
}
/** Remove the given specifiers from addon imports; drop emptied declarations. */
function removeSpecifiers(parsed, names) {
let changed = false;
const body = parsed._ast.program.body;
for (const stmt of body) {
if (!types.isImportDeclaration(stmt)) continue;
const src = stmt.source.value;
if (src !== ADDON_PACKAGE$1 && src !== CSF3_SUBPATH) continue;
const before = stmt.specifiers.length;
stmt.specifiers = stmt.specifiers.filter((spec) => {
if (!types.isImportSpecifier(spec)) return true;
const name = importedName(spec);
return name == null || !names.includes(name);
});
if (stmt.specifiers.length !== before) changed = true;
}
parsed._ast.program.body = body.filter((stmt) => {
if (!types.isImportDeclaration(stmt)) return true;
const src = stmt.source.value;
if (src !== ADDON_PACKAGE$1 && src !== CSF3_SUBPATH) return true;
return stmt.specifiers.length > 0;
});
return changed;
}
/** Move a root-package `mswLoader` specifier onto the /csf3 subpath. */
function retargetLoaderImport(parsed) {
const body = parsed._ast.program.body;
let loaderSpec = null;
let changed = false;
for (const stmt of body) {
if (!types.isImportDeclaration(stmt)) continue;
if (stmt.source.value !== ADDON_PACKAGE$1) continue;
const spec = stmt.specifiers.find((s) => types.isImportSpecifier(s) && importedName(s) === "mswLoader");
if (!spec) continue;
if (stmt.specifiers.length === 1) stmt.source = types.stringLiteral(CSF3_SUBPATH);
else {
stmt.specifiers = stmt.specifiers.filter((s) => s !== spec);
loaderSpec = spec;
}
changed = true;
}
if (loaderSpec) mergeIntoCsf3Import(parsed, loaderSpec);
return changed;
}
function mergeIntoCsf3Import(parsed, spec) {
for (const stmt of parsed._ast.program.body) {
if (!types.isImportDeclaration(stmt)) continue;
if (stmt.source.value !== CSF3_SUBPATH) continue;
if (!stmt.specifiers.some((s) => types.isImportSpecifier(s) && importedName(s) === importedName(spec))) stmt.specifiers.push(spec);
return;
}
parsed._ast.program.body.unshift(types.importDeclaration([spec], types.stringLiteral(CSF3_SUBPATH)));
}
/** Ensure `import { mswLoader } from 'msw-storybook-addon/csf3'` exists. */
function ensureLoaderImport(parsed, localName) {
for (const stmt of parsed._ast.program.body) {
if (!types.isImportDeclaration(stmt)) continue;
if (stmt.source.value !== CSF3_SUBPATH) continue;
if (stmt.specifiers.some((s) => types.isImportSpecifier(s) && importedName(s) === "mswLoader")) return false;
stmt.specifiers.push(types.importSpecifier(types.identifier(localName), types.identifier("mswLoader")));
return true;
}
parsed._ast.program.body.unshift(types.importDeclaration([types.importSpecifier(types.identifier(localName), types.identifier("mswLoader"))], types.stringLiteral(CSF3_SUBPATH)));
return true;
}
/** Ensure `import { setupWorker } from 'msw/browser'` exists. */
function ensureSetupWorkerImport(parsed) {
for (const stmt of parsed._ast.program.body) {
if (!types.isImportDeclaration(stmt)) continue;
if (stmt.source.value !== MSW_BROWSER) continue;
if (!stmt.specifiers.some((s) => types.isImportSpecifier(s) && importedName(s) === "setupWorker")) stmt.specifiers.push(types.importSpecifier(types.identifier("setupWorker"), types.identifier("setupWorker")));
return;
}
parsed._ast.program.body.unshift(types.importDeclaration([types.importSpecifier(types.identifier("setupWorker"), types.identifier("setupWorker"))], types.stringLiteral(MSW_BROWSER)));
}
function analyzeInitialize(parsed, localName, warnings) {
const body = parsed._ast.program.body;
const calls = [];
const knownNodes = /* @__PURE__ */ new Set();
for (const stmt of body) {
if (types.isImportDeclaration(stmt)) {
knownNodes.add(stmt);
continue;
}
if (types.isExpressionStatement(stmt) && types.isCallExpression(stmt.expression) && types.isIdentifier(stmt.expression.callee) && stmt.expression.callee.name === localName) {
calls.push({
stmt,
call: stmt.expression
});
knownNodes.add(stmt.expression.callee);
}
}
if (countIdentifierReferences(parsed._ast.program, localName, knownNodes) > 0) {
warnings.push(`\`${localName}\` is used in a way the codemod cannot rewrite (e.g. its return value is captured, or it is called conditionally). You will need to migrate this file yourself — see the "initialize is removed" section of the migration guide.`);
return "skip";
}
if (calls.length === 0) return null;
if (calls.length > 1) {
warnings.push(`Multiple \`${localName}(...)\` calls found; you will need to migrate this file yourself.`);
return "skip";
}
const { stmt, call } = calls[0];
const args = call.arguments;
if (args.some((a) => !types.isExpression(a))) {
warnings.push(`\`${localName}(...)\` receives arguments the codemod cannot carry over (e.g. spreads); you will need to migrate this file yourself.`);
return "skip";
}
if (args.length > 2) {
warnings.push(`\`${localName}(...)\` receives more than two arguments; you will need to migrate this file yourself.`);
return "skip";
}
return {
stmt,
options: args[0] ?? void 0,
handlers: args[1] ?? void 0
};
}
/** Count `Identifier` references to `name`, ignoring the given nodes and
* non-reference positions we can cheaply detect (member properties and
* non-computed object keys). Conservative: an unexplained occurrence makes
* the caller skip the file. */
function countIdentifierReferences(root, name, ignore) {
let count = 0;
const seen = /* @__PURE__ */ new Set();
function walk(node) {
if (node == null || typeof node !== "object" || seen.has(node)) return;
seen.add(node);
if (ignore.has(node)) return;
if (types.isIdentifier(node) && node.name === name) {
count++;
return;
}
for (const key of Object.keys(node)) {
if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
if (types.isMemberExpression(node) && key === "property" && !node.computed) continue;
if ((types.isObjectProperty(node) || types.isObjectMethod(node)) && key === "key" && !node.computed) continue;
const value = node[key];
if (Array.isArray(value)) {
for (const item of value) if (item && typeof item === "object" && "type" in item) walk(item);
} else if (value && typeof value === "object" && "type" in value) walk(value);
}
}
walk(root);
return count;
}
function buildSetupFunction(fold) {
const setupWorkerArgs = [];
if (fold.handlers) if (types.isArrayExpression(fold.handlers)) {
for (const el of fold.handlers.elements) if (el != null) setupWorkerArgs.push(el);
} else setupWorkerArgs.push(types.spreadElement(fold.handlers));
const startArgs = fold.options ? [fold.options] : [];
const bodyStatements = [
types.variableDeclaration("const", [types.variableDeclarator(types.identifier("worker"), types.callExpression(types.identifier("setupWorker"), setupWorkerArgs))]),
types.expressionStatement(types.awaitExpression(types.callExpression(types.memberExpression(types.identifier("worker"), types.identifier("start")), startArgs))),
types.returnStatement(types.identifier("worker"))
];
const fn = types.arrowFunctionExpression([], types.blockStatement(bodyStatements));
fn.async = true;
return fn;
}
function ensureLoaderCall(previewObj, loaderName, setupFn, imports) {
const makeCall = () => types.callExpression(types.identifier(loaderName), setupFn ? [setupFn] : []);
const loadersProp = findObjectProperty(previewObj, "loaders");
if (!loadersProp) {
previewObj.properties.unshift(types.objectProperty(types.identifier("loaders"), types.arrayExpression([makeCall()])));
return "changed";
}
const value = loadersProp.value;
if (types.isIdentifier(value) && value.name === (imports.mswLoader ?? loaderName)) {
loadersProp.value = types.arrayExpression([makeCall()]);
return "changed";
}
if (!types.isArrayExpression(value)) return "unchanged";
for (let i = 0; i < value.elements.length; i++) {
const el = value.elements[i];
if (el == null) continue;
if (types.isIdentifier(el) && el.name === (imports.mswLoader ?? loaderName)) {
value.elements[i] = makeCall();
return "changed";
}
if (types.isCallExpression(el) && types.isIdentifier(el.callee) && el.callee.name === (imports.mswLoader ?? loaderName)) {
if (setupFn) {
if (el.arguments.length > 0) return "conflict";
el.arguments.push(setupFn);
return "changed";
}
return "unchanged";
}
}
value.elements.push(makeCall());
return "changed";
}
function stripMswRef(obj, propName, refName) {
const prop = findObjectProperty(obj, propName);
if (!prop) return false;
const value = prop.value;
if (types.isIdentifier(value) && value.name === refName) {
obj.properties = obj.properties.filter((p) => p !== prop);
return true;
}
if (types.isArrayExpression(value)) {
const before = value.elements.length;
value.elements = value.elements.filter((el) => {
if (el == null) return true;
if (types.isIdentifier(el) && el.name === refName) return false;
if (types.isCallExpression(el) && types.isIdentifier(el.callee) && el.callee.name === refName) return false;
return true;
});
if (value.elements.length === 0 && before > 0) {
obj.properties = obj.properties.filter((p) => p !== prop);
return true;
}
return value.elements.length !== before;
}
return false;
}
function ensureFactoriesAddon(parsed, configObj, imports, setupFn, warnings) {
let localName = imports.addonDefault;
const addonsProp = findObjectProperty(configObj, "addons");
const addonsArray = addonsProp && types.isArrayExpression(addonsProp.value) ? addonsProp.value : null;
const removeNamespaceEntry = () => {
if (!imports.previewNamespace || !addonsArray) return false;
const before = addonsArray.elements.length;
addonsArray.elements = addonsArray.elements.filter((el) => !(el != null && types.isIdentifier(el) && el.name === imports.previewNamespace));
return addonsArray.elements.length !== before;
};
if (localName && addonsArray) for (const el of addonsArray.elements) {
if (el == null) continue;
const isBare = types.isIdentifier(el) && el.name === localName;
const isCall = types.isCallExpression(el) && types.isIdentifier(el.callee) && el.callee.name === localName;
if (!isBare && !isCall) continue;
if (setupFn) {
if (isCall && el.arguments.length > 0) {
warnings.push("A setup function could not be folded: the existing `addonMsw(...)` already receives one. You will need to merge them yourself.");
return "conflict";
}
if (isCall) {
el.arguments.push(setupFn);
removeNamespaceEntry();
return "changed";
}
}
return removeNamespaceEntry() ? "changed" : "unchanged";
}
if (!localName) {
localName = "addonMsw";
let attached = false;
for (const stmt of parsed._ast.program.body) {
if (!types.isImportDeclaration(stmt)) continue;
if (stmt.source.value !== ADDON_PACKAGE$1) continue;
stmt.specifiers.unshift(types.importDefaultSpecifier(types.identifier(localName)));
attached = true;
break;
}
if (!attached) parsed._ast.program.body.unshift(types.importDeclaration([types.importDefaultSpecifier(types.identifier(localName))], types.stringLiteral(ADDON_PACKAGE$1)));
}
const call = types.callExpression(types.identifier(localName), setupFn ? [setupFn] : []);
if (imports.previewNamespace && addonsArray) {
const nsIndex = addonsArray.elements.findIndex((el) => el != null && types.isIdentifier(el) && el.name === imports.previewNamespace);
if (nsIndex !== -1) {
addonsArray.elements[nsIndex] = call;
return "changed";
}
}
if (!addonsProp) {
configObj.properties.unshift(types.objectProperty(types.identifier("addons"), types.arrayExpression([call])));
return "changed";
}
if (!addonsArray) {
warnings.push("`addons` in definePreview is not an array literal; you will need to register `addonMsw()` yourself.");
return "conflict";
}
addonsArray.elements.unshift(call);
return "changed";
}
/** Setup function carried by `mswLoader(setup)` entries in `loaders`. */
function collectLoaderSetup(previewObj, loaderName) {
const prop = findObjectProperty(previewObj, "loaders");
if (!prop) return null;
const calls = [];
const consider = (el) => {
if (el != null && types.isCallExpression(el) && types.isIdentifier(el.callee) && el.callee.name === loaderName && el.arguments.length > 0) calls.push(el);
};
if (types.isArrayExpression(prop.value)) for (const el of prop.value.elements) consider(el);
else consider(prop.value);
if (calls.length === 0) return null;
if (calls.length > 1 || calls[0].arguments.length > 1) return "conflict";
const arg = calls[0].arguments[0];
return types.isExpression(arg) ? arg : "conflict";
}
/** Index of a bare-identifier entry in `addons: [...]`, or -1. */
function findAddonsEntryIndex(previewObj, name) {
const prop = findObjectProperty(previewObj, "addons");
if (!prop || !types.isArrayExpression(prop.value)) return -1;
return prop.value.elements.findIndex((el) => el != null && types.isIdentifier(el) && el.name === name);
}
/** Drop `import * as x from 'msw-storybook-addon/preview'` once its
* `addons` entry has been replaced. */
function removePreviewNamespaceImport(parsed) {
parsed._ast.program.body = parsed._ast.program.body.filter((stmt) => {
if (!types.isImportDeclaration(stmt)) return true;
if (stmt.source.value !== PREVIEW_SUBPATH) return true;
return !stmt.specifiers.some((s) => types.isImportNamespaceSpecifier(s));
});
}
function migratePreviewParameters(previewObj, warnings) {
const parametersProp = findObjectProperty(previewObj, "parameters");
if (!parametersProp || !types.isObjectExpression(parametersProp.value)) return false;
const parameters = parametersProp.value;
const mswProp = findObjectProperty(parameters, "msw");
if (!mswProp) return false;
const handlers = extractHandlers(mswProp.value);
if (!handlers) {
warnings.push("Preview-level `parameters.msw` has a shape the codemod does not recognize; you will need to migrate it to `beforeEach({ msw })` yourself.");
return false;
}
if (handlers.length > 0 && hasAnyProperty(previewObj, "beforeEach")) {
warnings.push("The preview already defines `beforeEach`; you will need to move the `parameters.msw` handlers into it yourself.");
return false;
}
if (handlers.length > 0) {
const beforeEachProp = buildBeforeEachProperty(handlers);
const insertIdx = previewObj.properties.indexOf(parametersProp);
previewObj.properties.splice(insertIdx, 0, beforeEachProp);
}
parameters.properties = parameters.properties.filter((p) => p !== mswProp);
if (parameters.properties.length === 0) previewObj.properties = previewObj.properties.filter((p) => p !== parametersProp);
return true;
}
function removeStatement(body, stmt) {
const idx = body.indexOf(stmt);
if (idx !== -1) body.splice(idx, 1);
}
//#endregion
//#region codemod/src/transforms/main.ts
/**
* `.storybook/main.{js,ts}` transform.
*
* Single responsibility: ensure `'msw-storybook-addon'` is in the
* `addons` array of the exported Storybook config. The registration is
* what `npx storybook automigrate csf-factories` reads later to wire the
* addon into a CSF Next preview.
*
* Idempotent: if the entry already exists — as a bare string, a
* resolver-wrapped call (`getAbsolutePath('msw-storybook-addon')`), or
* the object form `{ name, options }` — the file isn't touched.
*/
const ADDON_PACKAGE = "msw-storybook-addon";
function transformMain(source) {
let parsed;
try {
parsed = loadConfig(source).parse();
} catch {
return null;
}
if (!parsed._exportsObject) return null;
const addonsNode = parsed.getFieldNode(["addons"]);
if (!addonsNode) {
parsed.setFieldValue(["addons"], [ADDON_PACKAGE]);
return printConfig(parsed).code;
}
if (!types.isArrayExpression(addonsNode)) return null;
if (alreadyHasAddon(addonsNode)) return null;
const wrapper = detectWrapperName(addonsNode);
if (wrapper) parsed.appendNodeToArray(["addons"], types.callExpression(types.identifier(wrapper), [parsed.valueToNode(ADDON_PACKAGE)]));
else parsed.appendValueToArray(["addons"], ADDON_PACKAGE);
return printConfig(parsed).code;
}
/** Unwrap an `addons` array entry to the addon package name it refers to.
* Handles the bare string, the wrapped call (`getAbsolutePath('pkg')` or
* any single-string-argument resolver), and the object form — with either
* a string or a wrapped call as `name`. */
function resolveAddonName(el) {
if (types.isStringLiteral(el)) return el.value;
if (types.isCallExpression(el) && types.isIdentifier(el.callee) && el.arguments.length === 1 && types.isStringLiteral(el.arguments[0])) return el.arguments[0].value;
if (types.isObjectExpression(el)) {
const name = findProperty(el, "name");
if (name) return resolveAddonName(name.value);
}
return null;
}
function alreadyHasAddon(arr) {
return arr.elements.some((el) => el != null && resolveAddonName(el) === ADDON_PACKAGE);
}
/** The resolver function name used by existing entries, if any. */
function detectWrapperName(arr) {
for (const el of arr.elements) if (el && types.isCallExpression(el) && types.isIdentifier(el.callee) && el.arguments.length === 1 && types.isStringLiteral(el.arguments[0])) return el.callee.name;
return null;
}
function findProperty(obj, name) {
for (const prop of obj.properties) {
if (!types.isObjectProperty(prop)) continue;
const key = prop.key;
if (types.isIdentifier(key) && key.name === name) return prop;
if (types.isStringLiteral(key) && key.value === name) return prop;
}
}
//#endregion
//#region codemod/src/bin.ts
const DEFAULT_GLOB = "**/*.{stories,story}.{js,jsx,ts,tsx,mjs,mjsx,mts,mtsx}";
const MIGRATION_URL = "https://github.com/mswjs/msw-storybook-addon/blob/main/MIGRATION.md#from-2xx-to-3xx";
function parseArgs(argv) {
const out = {
glob: DEFAULT_GLOB,
preview: null,
main: null,
configDir: ".storybook",
dryRun: false
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--dry-run") out.dryRun = true;
else if (a === "--glob") out.glob = argv[++i] ?? out.glob;
else if (a === "--preview") out.preview = argv[++i] ?? null;
else if (a === "--main") out.main = argv[++i] ?? null;
else if (a === "--config-dir") out.configDir = argv[++i] ?? out.configDir;
else if (a === "--help" || a === "-h") {
printUsage();
process.exit(0);
}
}
return out;
}
function printUsage() {
console.log(`msw-storybook-migrate — migrate an msw-storybook-addon v2 setup to v3
Usage: npx msw-storybook-migrate [options]
--glob <pattern> Story-file glob. Default:
${DEFAULT_GLOB}
--preview <path> Path to preview file. Auto-detected from --config-dir.
--main <path> Path to main config. Auto-detected from --config-dir.
--config-dir <dir> Storybook config directory. Default: .storybook
--dry-run Don't write files; report what would change.
-h, --help Show this help.
`);
}
function findConfigFile(configDir, basename) {
for (const ext of [
"ts",
"tsx",
"mts",
"cts",
"js",
"jsx",
"mjs",
"cjs"
]) {
const p = join(configDir, `${basename}.${ext}`);
if (existsSync(p)) return p;
}
return null;
}
const IGNORED_DIRS = /* @__PURE__ */ new Set([
"node_modules",
"dist",
"build",
"storybook-static",
".git"
]);
/** Recursive story-file finder — `globToRegexp` from storybook internals
* plus a directory walk, so the CLI needs no glob dependency of its own. */
async function findStoryFiles(cwd, glob) {
const pattern = globToRegexp(glob.replace(/\\/g, "/"));
const out = [];
async function walk(dir) {
let entries;
try {
entries = await promises.readdir(dir, { withFileTypes: true });
} catch {
return;
}
await Promise.all(entries.map(async (entry) => {
const abs = join(dir, entry.name);
if (entry.isDirectory()) {
if (!IGNORED_DIRS.has(entry.name)) await walk(abs);
} else if (entry.isFile()) {
const rel = relative(cwd, abs).split(sep).join("/");
if (pattern.test(rel)) out.push(abs);
}
}));
}
await walk(cwd);
return out.sort();
}
/** Tiny concurrency limiter — avoids a p-limit dependency. */
function createLimiter(concurrency) {
let active = 0;
const queue = [];
const next = () => {
active--;
queue.shift()?.();
};
return async function limit(fn) {
if (active >= concurrency) await new Promise((resolveWait) => queue.push(resolveWait));
active++;
try {
return await fn();
} finally {
next();
}
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const cwd = process.cwd();
const configDir = resolve(cwd, args.configDir);
logger.intro(`msw-storybook-addon migration${args.dryRun ? " (dry run)" : ""}`);
const previewPath = args.preview ? resolve(cwd, args.preview) : findConfigFile(configDir, "preview");
const mainPath = args.main ? resolve(cwd, args.main) : findConfigFile(configDir, "main");
const transformed = [];
const failed = [];
let unmodified = 0;
const warnings = [];
const unrecognizedShape = [];
const existingBeforeEach = [];
let previewIsCsfNext;
const check = CLI_COLORS.success("✔");
let flushedCount = 0;
const flushPhase = () => {
const phaseFiles = transformed.slice(flushedCount).sort();
flushedCount = transformed.length;
if (phaseFiles.length > 0) logger.log(phaseFiles.map((file) => `${check} ${file}`).join("\n"));
};
logger.step("Migrating your Storybook configuration...");
if (previewPath && existsSync(previewPath)) try {
const source = await promises.readFile(previewPath, "utf-8");
const result = transformPreview(source, { migrateParameters: true });
previewIsCsfNext = result.csfNext;
for (const w of result.warnings) warnings.push(`${short(previewPath)}: ${w}`);
if (result.code != null && result.code !== source) {
if (!args.dryRun) await promises.writeFile(previewPath, await formatFileContent(previewPath, result.code), "utf-8");
transformed.push(short(previewPath));
} else unmodified++;
} catch (err) {
failed.push(`${short(previewPath)}: ${String(err)}`);
}
else warnings.push(`Preview file not found${args.preview ? `: ${args.preview}` : ` in ${short(configDir)}`}.`);
if (mainPath && existsSync(mainPath)) try {
const source = await promises.readFile(mainPath, "utf-8");
const out = transformMain(source);
if (out != null && out !== source) {
if (!args.dryRun) await promises.writeFile(mainPath, await formatFileContent(mainPath, out), "utf-8");
transformed.push(short(mainPath));
} else unmodified++;
} catch (err) {
failed.push(`${short(mainPath)}: ${String(err)}`);
}
else warnings.push(`Main config not found${args.main ? `: ${args.main}` : ` in ${short(configDir)}`}.`);
flushPhase();
logger.step("Migrating `parameters.msw` in your story files...");
const limit = createLimiter(Math.max(1, os.cpus().length - 1));
const storyFiles = await findStoryFiles(cwd, args.glob);
if (storyFiles.length === 0) warnings.push(`No story files matched glob "${args.glob}".`);
await Promise.all(storyFiles.map((file) => limit(async () => {
try {
const source = await promises.readFile(file, "utf-8");
const result = transformStory(source);
const shapes = result.skippedStories.filter((s) => s.reason === "unrecognized-shape").map((s) => s.story);
const merges = result.skippedStories.filter((s) => s.reason === "existing-before-each").map((s) => s.story);
if (shapes.length > 0) unrecognizedShape.push(`${short(file)}: ${shapes.join(", ")}`);
if (merges.length > 0) existingBeforeEach.push(`${short(file)}: ${merges.join(", ")}`);
if (result.code != null && result.code !== source) {
if (!args.dryRun) await promises.writeFile(file, await formatFileContent(file, result.code), "utf-8");
transformed.push(short(file));
} else unmodified++;
} catch (err) {
failed.push(`${short(file)}: ${String(err)}`);
}
})));
flushPhase();
logger.log(CLI_COLORS.muted(`${transformed.length} file(s) ${args.dryRun ? "would be " : ""}updated, ${unmodified} already up to date`));
if (failed.length > 0) logger.error(failed.join("\n"));
if (warnings.length > 0 || unrecognizedShape.length > 0 || existingBeforeEach.length > 0) {
const sections = [];
if (warnings.length > 0) sections.push(warnings.map((w) => `• ${w}`).join("\n"));
if (unrecognizedShape.length > 0) sections.push(`Could not recognize the \`parameters.msw\` shape — migrate them manually:\n${unrecognizedShape.map((w) => ` • ${w}`).join("\n")}`);
if (existingBeforeEach.length > 0) sections.push(`\`beforeEach\` already defined — migrate \`parameters.msw\` manually:\n${existingBeforeEach.map((w) => ` • ${w}`).join("\n")}`);
sections.push("Don't worry — the addon still supports `parameters.msw`, so everything keeps working until you migrate.");
sections.push(`See the migration guide:\n${CLI_COLORS.cta(MIGRATION_URL)}`);
logger.warn(`The codemod could not migrate everything:\n${sections.join("\n\n")}`);
}
if (previewIsCsfNext === false) logger.logBox(dedent`
All set, happy mocking!
v3 of this addon was designed to work best with CSF Next, which you can migrate to with this command:
${CLI_COLORS.cta("npx storybook automigrate csf-factories")}
To learn more about CSF Next, see the Storybook docs:
${CLI_COLORS.cta("https://storybook.js.org/docs/api/csf/csf-next")}
`);
else if (previewIsCsfNext === true) logger.logBox(dedent`
All set. Happy mocking!
`);
else logger.logBox(dedent`
v3 of this addon was designed to work best with CSF Next, which you can migrate to with this command:
${CLI_COLORS.cta("npx storybook automigrate csf-factories")}
To learn more about CSF Next, see the Storybook docs:
${CLI_COLORS.cta("https://storybook.js.org/docs/api/csf/csf-next")}
`);
if (args.dryRun) logger.outro("Dry run — nothing was written. Re-run without --dry-run to apply.");
else logger.outro(failed.length > 0 ? "Finished with errors." : "Done!");
process.exit(failed.length > 0 ? 1 : 0);
}
function short(p) {
const cwd = process.cwd();
return p.startsWith(cwd) ? p.slice(cwd.length + 1) : p;
}
main().catch((err) => {
logger.error(String(err?.stack ?? err));
process.exit(1);
});
//#endregion
export { parseArgs };