UNPKG

msw-storybook-addon

Version:

Mock APIs in Storybook using Mock Service Worker.

1,080 lines (1,074 loc) 45.2 kB
#!/usr/bin/env node 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 };