Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
604a3e9
fix(codemod): parse with its own Babel so migrate runs on Storybook 11
kasperpeulen Oct 20, 2026
ae10f39
refactor(codemod): edit through Storybook 11's csf-tools instead of a…
kasperpeulen Oct 20, 2026
39477cd
fix(codemod): leave files alone when csf-tools refuses an edit
kasperpeulen Oct 20, 2026
f7c77e3
refactor(codemod): use the csf-tools transaction, inspect and import API
kasperpeulen Oct 20, 2026
755f134
refactor(codemod): simplify the preview, story and main transforms
kasperpeulen Oct 20, 2026
feb122c
test(codemod): drop the source scan for removed Storybook APIs
kasperpeulen Oct 20, 2026
0bf2e38
fix(codemod): look up initialize references through the file's scopes
kasperpeulen Oct 20, 2026
535eef6
test(codemod): expect the latest csf-tools output for CSF2 and spread…
kasperpeulen Oct 20, 2026
eef6e4a
fix(codemod): read parameters.msw from the parameters a story writes
kasperpeulen Oct 20, 2026
2ba7995
fix(codemod): warn instead of dropping initialize options the preview…
kasperpeulen Oct 20, 2026
e8dd024
refactor(codemod): let removeImport and setImport skip what is not there
kasperpeulen Oct 20, 2026
09ac444
feat(codemod): stop migrate on a Storybook without the ESTree csf-tools
kasperpeulen Oct 20, 2026
cffe6e1
feat(codemod): keep the Babel transforms for Storybook 9 and 10
kasperpeulen Oct 20, 2026
92f9106
fix(codemod): print the migration summary on Storybook 9
kasperpeulen Oct 20, 2026
d4f333a
test(codemod): pick the transform suite once and print which one runs
kasperpeulen Oct 20, 2026
8852dac
refactor(codemod): hold both transform paths to the Babel path's types
kasperpeulen Oct 20, 2026
20e6294
ci: run the codemod tests
kasperpeulen Oct 20, 2026
65d28cf
chore: run against the Storybook canary with the public csf-tools API
kasperpeulen Oct 21, 2026
24321fb
refactor(codemod): use the public csf-tools API in the ESTree transforms
kasperpeulen Oct 21, 2026
6ce7823
chore: keep the Storybook dev dependencies that run the Babel transforms
kasperpeulen Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ jobs:
- name: Test (Types)
run: pnpm test:ts

- name: Test (Codemod)
run: pnpm test:codemod

- name: Test (Stories)
run: pnpm test:stories

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { transformMain } from '../src/transforms/main'
import { expect, it } from 'vitest'
import { dedent } from 'ts-dedent'
import { babel } from '../csf-tools'

const describe = babel.describe
const { transformMain } = await babel.load(
() => import('../../src/transforms/babel/main')
)

describe('transformMain', () => {
it('adds the addon when missing', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { transformPreview } from '../src/transforms/preview'
import { expect, it } from 'vitest'
import { dedent } from 'ts-dedent'
import { babel } from '../csf-tools'

const describe = babel.describe
const { transformPreview } = await babel.load(
() => import('../../src/transforms/babel/preview')
)

describe('transformPreview — CSF 3.0', () => {
it('returns null when there is no msw-storybook-addon usage', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { transformStory } from '../src/transforms/stories'
import { expect, it } from 'vitest'
import { dedent } from 'ts-dedent'
import { babel } from '../csf-tools'

const describe = babel.describe
const { transformStory } = await babel.load(
() => import('../../src/transforms/babel/stories')
)

// Each test pins the full `transformStory` result as an inline snapshot so
// the exact rewrite (parameters.msw -> beforeEach({ msw }), other params
Expand Down
27 changes: 27 additions & 0 deletions codemod/__tests__/csf-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, inject } from 'vitest'
import type { Transforms } from './global-setup'

/**
* The codemod has one implementation per csf-tools generation: Babel for
* Storybook 9 and 10, ESTree for Storybook 11. Each suite runs on the
* Storybook it is written for, and is skipped with the reason otherwise.
*/
interface CsfToolsSuite {
describe: (name: string, fn: () => void) => void
/** Imports a transform, which cannot even load on the other Storybook. */
load: <T>(load: () => Promise<T>) => Promise<T>
}

function suite(transforms: Transforms, needs: string): CsfToolsSuite {
const available = inject('transforms') === transforms
return {
describe: (name, fn) =>
available
? describe(name, fn)
: describe.skip(`${name} (needs ${needs})`, fn),
load: (load) => (available ? load() : Promise.resolve({} as never))
}
}

export const babel = suite('babel', 'the Babel csf-tools of Storybook 9 or 10')
export const estree = suite('estree', 'the ESTree csf-tools of Storybook 11')
236 changes: 236 additions & 0 deletions codemod/__tests__/estree/main.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { expect, it } from 'vitest'
import { dedent } from 'ts-dedent'
import { estree } from '../csf-tools'

const describe = estree.describe
const { transformMain } = await estree.load(
() => import('../../src/transforms/estree/main')
)

describe('transformMain', () => {
it('adds the addon when missing', () => {
const src = dedent`
import type { StorybookConfig } from '@storybook/react-vite'
export default {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.tsx'],
addons: ['@storybook/addon-essentials'],
} satisfies StorybookConfig
`
expect(transformMain(src)).toMatchInlineSnapshot(`
"import type { StorybookConfig } from '@storybook/react-vite'
export default {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.tsx'],
addons: ['@storybook/addon-essentials', 'msw-storybook-addon'],
} satisfies StorybookConfig"
`)
})

it('is idempotent when the addon is already a bare string', () => {
const src = dedent`
export default {
addons: ['msw-storybook-addon'],
}
`
expect(transformMain(src)).toBeNull()
})

it('is idempotent when the addon is already in object form', () => {
const src = dedent`
export default {
addons: [{ name: 'msw-storybook-addon', options: {} }],
}
`
expect(transformMain(src)).toBeNull()
})

it('is idempotent when the addon is already wrapped in getAbsolutePath', () => {
const src = dedent`
function getAbsolutePath(value) {
return dirname(require.resolve(join(value, 'package.json')))
}
export default {
addons: [getAbsolutePath('msw-storybook-addon')],
}
`
expect(transformMain(src)).toBeNull()
})

it('is idempotent when the addon is in object form with a wrapped name', () => {
const src = dedent`
export default {
addons: [{ name: getAbsolutePath('msw-storybook-addon'), options: {} }],
}
`
expect(transformMain(src)).toBeNull()
})

it('follows the getAbsolutePath convention of existing entries', () => {
const src = dedent`
import type { StorybookConfig } from '@storybook/react-vite'
export default {
framework: getAbsolutePath('@storybook/react-vite'),
addons: [getAbsolutePath('@storybook/addon-docs')],
} satisfies StorybookConfig
`
expect(transformMain(src)).toMatchInlineSnapshot(`
"import type { StorybookConfig } from '@storybook/react-vite'
export default {
framework: getAbsolutePath('@storybook/react-vite'),
addons: [getAbsolutePath('@storybook/addon-docs'), getAbsolutePath('msw-storybook-addon')],
} satisfies StorybookConfig"
`)
})

it('follows any single-string-argument resolver convention, not just getAbsolutePath', () => {
const src = dedent`
export default {
addons: [wrapForPnp('@storybook/addon-docs')],
}
`
expect(transformMain(src)).toMatchInlineSnapshot(`
"export default {
addons: [wrapForPnp('@storybook/addon-docs'), wrapForPnp('msw-storybook-addon')],
}"
`)
})

it('handles a defineMain-wrapped config', () => {
const src = dedent`
import { defineMain } from '@storybook/react-vite/node'

export default defineMain({
framework: '@storybook/react-vite',
addons: ['@storybook/addon-docs'],
})
`
expect(transformMain(src)).toMatchInlineSnapshot(`
"import { defineMain } from '@storybook/react-vite/node'

export default defineMain({
framework: '@storybook/react-vite',
addons: ['@storybook/addon-docs', 'msw-storybook-addon'],
})"
`)
})

it('creates the addons array if missing', () => {
const src = dedent`
export default {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.tsx'],
}
`
expect(transformMain(src)).toMatchInlineSnapshot(`
"export default {
framework: '@storybook/react-vite',
stories: ['../src/**/*.stories.tsx'],
addons: ['msw-storybook-addon'],
}"
`)
})

it('returns null when addons is a method', () => {
const src = dedent`
export default {
addons() {
return ['@storybook/addon-docs']
},
}
`
expect(transformMain(src)).toBeNull()
})

it('keeps the quote style of the first 500 tokens, directives included', () => {
const src = dedent`
'use strict'
module.exports = { stories: ["../src"], addons: ['x'] }
`
expect(transformMain(src)).toMatchInlineSnapshot(`
"'use strict'
module.exports = { stories: ["../src"], addons: ['x', 'msw-storybook-addon'] }"
`)
})

it('carries the addons of a spread config forward', () => {
const src = dedent`
import { shared } from './shared'
export default { ...shared, framework: '@storybook/react-vite' }
`
expect(transformMain(src)).toMatchInlineSnapshot(`
"import { shared } from './shared'
export default { ...shared, framework: '@storybook/react-vite', addons: [...(shared.addons ?? []), 'msw-storybook-addon'] }"
`)
})

it('reports an addons field csf-tools cannot edit', () => {
// The config binding is used elsewhere, so csf-tools will not edit it.
const src = dedent`
const config = { framework: '@storybook/react-vite' }
export default config
console.log(config)
`
expect(() => transformMain(src)).toThrow(/add it yourself/)
})

it('is idempotent when csf-tools could not add the addon', () => {
// A spread after the field, or a config binding used elsewhere, makes
// csf-tools refuse to edit `addons`, but not to read it.
expect(
transformMain(
`export default { addons: ['msw-storybook-addon'], ...base }`
)
).toBeNull()
expect(
transformMain(dedent`
const config = { addons: ['msw-storybook-addon'] }
export default config
console.log(config)
`)
).toBeNull()
})

it('reports an addons constant used elsewhere', () => {
const src = dedent`
const addons = ['@storybook/addon-docs']
export default { addons }
console.log(addons)
`
expect(() => transformMain(src)).toThrow(/add it yourself/)
})

it('reports a config wrapped in a call it cannot edit', () => {
expect(() =>
transformMain(`export default withX({ addons: ['a'] })`)
).toThrow(/add it yourself/)
expect(() => transformMain(`export default makeConfig()`)).toThrow(
/add it yourself/
)
})

it('returns null for a config of named exports', () => {
expect(transformMain(`export const addons = ['a']`)).toBeNull()
})

it('adds the addon to a local addons constant', () => {
const src = dedent`
const addons = ['@storybook/addon-docs']
export default { addons }
`
expect(transformMain(src)).toMatchInlineSnapshot(`
"const addons = ['@storybook/addon-docs', 'msw-storybook-addon']
export default { addons }"
`)
})

it('returns null when addons is not a plain array (e.g. spread variable)', () => {
const src = dedent`
import { sharedAddons } from './shared'
export default {
addons: sharedAddons,
}
`
expect(transformMain(src)).toBeNull()
})
})
Loading