PS-SLã®äœã
æšã§ãã ã¢ããã³ãã«ã¬ã³ããŒ13æ¥ç®ã«ãªããŸã ãã®èšäºã§ã¯ãNext.js 14ãããžã§ã¯ãã«StorybookãšPlaywright E2Eãã¹ããå°å
¥ããChromaticã䜿ã£ãŠGitHub Actionsã§èªåããžã¥ã¢ã«ãã¹ããå®çŸãããŸã§ã®éçšã解説ããŸãã Chromaticãšã¯ Chromatic ã¯ãStorybookããŒã ãæäŸããããžã¥ã¢ã«ãã¹ãã»UIã¬ãã¥ãŒãã©ãããã©ãŒã ã§ãã ãªãChromaticã䜿ãã®ã èª²é¡ Chromaticã§ã®è§£æ±º CSSã®å€æŽãä»ã®ã³ã³ããŒãã³ãã«åœ±é¿ããŠããªããäžå® å
šStoriesã®ã¹ãããã·ã§ãããèªåæ¯èŒ PRã¬ãã¥ãŒã§UIã確èªããã®ãé¢å ãã¬ãã¥ãŒURLãèªåã§PRã«ã³ã¡ã³ãããã ãã¶ã€ã³ã·ã¹ãã ã®ããã¥ã¡ã³ããå€ããªã åžžã«ææ°ã®Storybookããã¹ãã£ã³ã°ããã E2Eãã¹ãã®ç»é¢ãã£ããã£ã管çããã Playwright飿ºã§E2Eãããžã¥ã¢ã«ãã¹ãå ä»åã®ãããžã§ã¯ãæ§æ project/ âââ frontend/ â âââ src/ â â âââ components/ â â â âââ ui/ # shadcn/ui ããŒã¹ã®å
±éã³ã³ããŒãã³ã â â â â âââ button.tsx â â â â âââ button.stories.tsx â â â â âââ card.tsx â â â â âââ card.stories.tsx â â â â âââ input.tsx â â â â âââ input.stories.tsx â â â â âââ textarea.tsx â â â âââ chat/ # ãã£ããæ©èœã³ã³ããŒãã³ã â â â â âââ MessageList.tsx â â â â âââ MessageList.stories.tsx â â â â âââ MessageInput.tsx â â â â âââ ModeSelector.tsx â â â âââ import/ # ã€ã³ããŒãæ©èœã³ã³ããŒãã³ã â â â âââ FileUploader.tsx â â â âââ ImportProgress.tsx â â âââ app/ â âââ e2e/ # Playwright E2Eãã¹ã â â âââ home.spec.ts â âââ .storybook/ â â âââ main.ts â â âââ preview.ts â âââ package.json âââ backend/ âââ .github/ âââ workflows/ âââ ci.yml æè¡ã¹ã¿ã㯠Next.js 14 (App Router) shadcn/ui + Tailwind CSS Storybook 8.4 Playwright @chromatic-com/playwright Storybookã®ã»ããã¢ãã 1. Storybookã®åæå cd frontend npx storybook@latest init 2. èšå®ãã¡ã€ã« .storybook/main.ts : import type { StorybookConfig } from '@storybook/nextjs' ; const config : StorybookConfig = { stories : [ '../src/**/*.mdx' , '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)' ] , addons : [ '@storybook/addon-essentials' , '@storybook/addon-interactions' , '@storybook/addon-links' , ] , framework : { name : '@storybook/nextjs' , options : { } , } , docs : { autodocs : 'tag' , } , staticDirs : [ '../public' ] , } ; export default config ; .storybook/preview.ts : import type { Preview } from '@storybook/react' ; import '../src/app/globals.css' ; // Tailwind CSSãèªã¿èŸŒã const preview : Preview = { parameters : { controls : { matchers : { color : / (background|color)$ / i , date : / Date$ / i , } , } , } , } ; export default preview ; 3. package.jsonã®ã¹ã¯ãªãã { "scripts" : { "storybook" : "storybook dev -p 6006" , "build-storybook" : "storybook build" , "chromatic" : "chromatic --exit-zero-on-changes" } } ã³ã³ããŒãã³ãã®Storiesäœæ å®éã«äœæããStoriesã®äŸã玹ä»ããŸãã UIã³ã³ããŒãã³ãïŒButtonïŒ src/components/ui/button.stories.tsx : import type { Meta , StoryObj } from '@storybook/react' ; import { Button } from './button' ; const meta : Meta < typeof Button > = { title : 'UI/Button' , component : Button , parameters : { layout : 'centered' , } , tags : [ 'autodocs' ] , argTypes : { variant : { control : 'select' , options : [ 'default' , 'destructive' , 'outline' , 'secondary' , 'ghost' , 'link' ] , } , size : { control : 'select' , options : [ 'default' , 'sm' , 'lg' , 'icon' ] , } , } , } ; export default meta ; type Story = StoryObj < typeof meta > ; export const Default : Story = { args : { children : 'Button' , variant : 'default' , } , } ; export const Secondary : Story = { args : { children : 'Secondary' , variant : 'secondary' , } , } ; export const Outline : Story = { args : { children : 'Outline' , variant : 'outline' , } , } ; export const Destructive : Story = { args : { children : 'Destructive' , variant : 'destructive' , } , } ; export const Small : Story = { args : { children : 'Small' , size : 'sm' , } , } ; export const Large : Story = { args : { children : 'Large' , size : 'lg' , } , } ; æ©èœã³ã³ããŒãã³ãïŒMessageListïŒ ãã£ããæ©èœã®ã¡ãã»ãŒãžäžèЧã³ã³ããŒãã³ããæ§ã
ãªç¶æ
ãStoriesã§è¡šçŸããŸãã src/components/chat/MessageList.stories.tsx : import type { Meta , StoryObj } from '@storybook/react' ; import { MessageList } from './MessageList' ; import type { Message } from '@/types' ; const meta : Meta < typeof MessageList > = { title : 'Chat/MessageList' , component : MessageList , parameters : { layout : 'fullscreen' , } , tags : [ 'autodocs' ] , decorators : [ ( Story ) => ( < div className = "h-[500px] bg-gray-50" > < Story / > < / div > ) , ] , } ; export default meta ; type Story = StoryObj < typeof meta > ; // ã¢ãã¯ããŒã¿ const mockMessages : Message [ ] = [ { id : '1' , role : 'user' , content : 'å·åŽã·ã¹ãã ã®å€æŽç¹ãæããŠãã ãã' , timestamp : new Date ( '2024-01-15T10:00:00' ) , } , { id : '2' , role : 'assistant' , content : 'å·åŽã·ã¹ãã ã«ã¯ä»¥äžã®å€æŽç¹ããããŸãïŒ\n\n1. å·åŽãã¡ã³ã®åœ¢ç¶ã倿Ž\n2. ããŒãã·ã³ã¯ã®çŽ æã倿Ž' , sources : [ { id : 'src-1' , content : 'No.15: å·åŽã·ã¹ãã ' , excel_row : 15 } , ] , timestamp : new Date ( '2024-01-15T10:00:05' ) , } , ] ; // 空ã®ç¶æ
export const Empty : Story = { args : { messages : [ ] , isLoading : false , onExport : ( ) => { } , } , } ; // ã¡ãã»ãŒãžãããç¶æ
export const WithMessages : Story = { args : { messages : mockMessages , isLoading : false , onExport : ( ) => { } , } , } ; // ããŒãã£ã³ã°ç¶æ
export const Loading : Story = { args : { messages : [ mockMessages [ 0 ] ] , isLoading : true , onExport : ( ) => { } , } , } ; // ãšã¯ã¹ããŒããã¿ã³ä»ã export const WithExportButton : Story = { args : { messages : [ ... mockMessages , { ... mockMessages [ 1 ] , id : '3' , exportReady : true , // ãšã¯ã¹ããŒãå¯èœãã©ã° } , ] , isLoading : false , onExport : ( ) => alert ( 'Export clicked' ) , } , } ; ã€ã³ããŒãæ©èœïŒImportProgressïŒ ãã¡ã€ã«ã€ã³ããŒãã®é²æè¡šç€ºã³ã³ããŒãã³ããåç¶æ
ãStoriesã§ç¶²çŸ
ããŸãã src/components/import/ImportProgress.stories.tsx : import type { Meta , StoryObj } from '@storybook/react' ; import { ImportProgress } from './ImportProgress' ; const meta : Meta < typeof ImportProgress > = { title : 'Import/ImportProgress' , component : ImportProgress , parameters : { layout : 'centered' , } , tags : [ 'autodocs' ] , decorators : [ ( Story ) => ( < div className = "w-[400px]" > < Story / > < / div > ) , ] , } ; export default meta ; type Story = StoryObj < typeof meta > ; export const Idle : Story = { args : { status : 'idle' , result : null , } , } ; export const Uploading : Story = { args : { status : 'uploading' , result : null , } , } ; export const Success : Story = { args : { status : 'success' , result : { success : true , message : 'Excelãã¡ã€ã«ã®ã€ã³ããŒããå®äºããŸãã' , documentCount : 25 , } , } , } ; export const Error : Story = { args : { status : 'error' , result : { success : false , message : 'ãã¡ã€ã«åœ¢åŒãäžæ£ã§ããxlsx ãŸã㯠xls ãã¡ã€ã«ãã¢ããããŒãããŠãã ããã' , } , } , } ; Playwright E2Eãã¹ãã®Chromaticå¯Ÿå¿ 1. ããã±ãŒãžã®ã€ã³ã¹ããŒã« npm install --save-dev @chromatic-com/playwright 2. E2Eãã¹ãã®ä¿®æ£ éåžžã® @playwright/test ã®ä»£ããã«ã @chromatic-com/playwright ããã€ã³ããŒãããŸãã e2e/home.spec.ts : // Before: import { test, expect } from '@playwright/test'; // After: import { test , expect } from '@chromatic-com/playwright' ; test . describe ( 'Home Page' , ( ) => { test ( 'should display the header' , async ( { page } ) => { await page . goto ( '/' ) ; await expect ( page . locator ( 'h1' ) ) . toContainText ( 'Excel RAG' ) ; // ãã¹ãçµäºæã«èªåçã«ã¹ãããã·ã§ãããååŸããã } ) ; test ( 'should have navigation links' , async ( { page } ) => { await page . goto ( '/' ) ; await expect ( page . getByRole ( 'link' , { name : 'QA' } ) ) . toBeVisible ( ) ; await expect ( page . getByRole ( 'link' , { name : 'Import' } ) ) . toBeVisible ( ) ; } ) ; test ( 'should display mode selector' , async ( { page } ) => { await page . goto ( '/' ) ; await expect ( page . getByRole ( 'button' , { name : 'QA' } ) ) . toBeVisible ( ) ; await expect ( page . getByRole ( 'button' , { name : 'Export' } ) ) . toBeVisible ( ) ; } ) ; test ( 'should have message input' , async ( { page } ) => { await page . goto ( '/' ) ; await expect ( page . getByPlaceholder ( '質åãå
¥åããŠãã ãã' ) ) . toBeVisible ( ) ; } ) ; } ) ; test . describe ( 'Import Page' , ( ) => { test ( 'should display file uploader' , async ( { page } ) => { await page . goto ( '/import' ) ; await expect ( page . locator ( 'h2' ) ) . toContainText ( 'Excel垳祚ã€ã³ããŒã' ) ; } ) ; } ) ; 3. Playwrightèšå® playwright.config.ts ã¯éåžžéãã§OKïŒ import { defineConfig , devices } from '@playwright/test' ; export default defineConfig ( { testDir : './e2e' , fullyParallel : true , forbidOnly : ! ! process . env . CI , retries : process . env . CI ? 2 : 0 , workers : process . env . CI ? 1 : undefined , reporter : 'html' , use : { baseURL : 'http://localhost:3000' , trace : 'on-first-retry' , } , projects : [ { name : 'chromium' , use : { ... devices [ 'Desktop Chrome' ] } , } , ] , webServer : { command : 'npm run dev' , url : 'http://localhost:3000' , reuseExistingServer : ! process . env . CI , } , } ) ; GitHub Actionsã§ã®èªåå StorybookãšE2Eã§å¥ã
ã®Chromaticãããžã§ã¯ãã䜿çšããèšå®ã§ãã å®å
šãªã¯ãŒã¯ãããŒèšå® .github/workflows/ci.yml : name : CI on : push : branches : [ master , develop ] pull_request : branches : [ master , develop ] concurrency : group : $ { { github.workflow } } - $ { { github.ref } } cancel-in-progress : true jobs : # =========================== # Chromatic - Storybook # =========================== chromatic-storybook : name : Chromatic - Storybook runs-on : ubuntu - latest defaults : run : working-directory : frontend steps : - uses : actions/checkout@v4 with : fetch-depth : 0 # å
šå±¥æŽååŸïŒå·®åæ¯èŒã«å¿
èŠïŒ - name : Setup Node.js uses : actions/setup - node@v4 with : node-version : '20' cache : 'npm' cache-dependency-path : frontend/package - lock.json - name : Install dependencies run : npm ci - name : Build Storybook run : npm run build - storybook - name : Publish Storybook to Chromatic id : chromatic uses : chromaui/action@latest with : projectToken : $ { { secrets.CHROMATIC_STORYBOOK_TOKEN } } workingDir : frontend storybookBuildDir : storybook - static exitZeroOnChanges : true autoAcceptChanges : master onlyChanged : true - name : Comment Storybook URL on PR if : github.event_name == 'pull_request' uses : actions/github - script@v7 with : script : | const storybookUrl = '${{ steps.chromatic.outputs.storybookUrl }}'; const buildUrl = '${{ steps.chromatic.outputs.buildUrl }}'; if (storybookUrl) { github.rest.issues.createComment( { issue_number : context.issue.number , owner : context.repo.owner , repo : context.repo.repo , body : ` ## Storybook Preview\n\n- [View Storybook](${storybookUrl})\n- [View Chromatic Build](${buildUrl})` } ); } # =========================== # Chromatic - E2E (Playwright) # =========================== chromatic-e2e : name : Chromatic - E2E runs-on : ubuntu - latest defaults : run : working-directory : frontend steps : - uses : actions/checkout@v4 with : fetch-depth : 0 - name : Setup Node.js uses : actions/setup - node@v4 with : node-version : '20' cache : 'npm' cache-dependency-path : frontend/package - lock.json - name : Install dependencies run : npm ci - name : Install Playwright browsers run : npx playwright install - - with - deps chromium - name : Run Playwright tests run : npx playwright test - name : Publish E2E to Chromatic id : chromatic - e2e uses : chromaui/action@latest with : projectToken : $ { { secrets.CHROMATIC_E2E_TOKEN } } workingDir : frontend playwright : true exitZeroOnChanges : true å¿
èŠãªGitHub Secrets GitHubãªããžããªã® Settings > Secrets and variables > Actions ã§èšå®ïŒ Secretå çšé ååŸæ¹æ³ CHROMATIC_STORYBOOK_TOKEN Storybookçš Chromaticã§ãStorybookããããžã§ã¯ããäœæ CHROMATIC_E2E_TOKEN E2Eçš Chromaticã§ãE2Eããããžã§ã¯ããäœæ ãªãåããã®ãïŒ StorybookãšE2Eã¯æ§è³ªãç°ãªããããå¥ãããžã§ã¯ãã§ç®¡çããæ¹ãèŠããããªããŸãïŒ Storybook: ã³ã³ããŒãã³ãåäœã®ã¹ãããã·ã§ãã E2E: ããŒãžå
šäœã®ã¹ãããã·ã§ãã Chromaticã®ç»é¢ãèŠãŠã¿ã å®éã«PRãäœæãããtargeãã©ã³ãã«PRãããŒãžããããšä»¥äžã®ããã«ç¢ºèªããããšãã§ããŸãã Buildããšã«ã©ã®ã³ã³ããŒãã³ããã©ã®ããã«å€æŽãããŠããããã¹ãããã·ã§ããã§ç¢ºèªã§ããããã«ãªã£ãŠããããããæ¿èªãããã³ã¡ã³ããæ®ãããããããšãã§ããŸãã UIã¯ãœãŒã¹ã³ãŒããçºããŠããã ãã§ã¯ãªããªãå·®åããããã«ããããå®éã®ããžã¥ã¢ã«ãæè»œã«ã§ããã®ã¯ãšãŠãè¯ãæ©èœã ãšæããŸãã ããã£ããã€ã³ããšè§£æ±ºç 1.  chromatic --playwright ã§ã¢ãŒã«ã€ããèŠã€ãããªããšã©ãŒ Chromatic archives directory cannot be found: /path/to/frontend/test-results/chromatic-archives åå : Playwrightãã¹ããå®è¡ããåã« chromatic --playwright ãå®è¡ããŠãã 解決ç : å
ã«Playwrightãã¹ããå®è¡ããŠããChromaticã«ã¢ããããŒã - name : Run Playwright tests run : npx playwright test - name : Publish E2E to Chromatic uses : chromaui/action@latest with : playwright : true 2.  takeArchive ãèŠã€ãããªããšã©ãŒ Module '"@chromatic-com/playwright"' has no exported member 'takeArchive'. åå : å€ãããã¥ã¡ã³ããåç
§ããŠãã 解決ç :  @chromatic-com/playwright ãã㯠test ãš expect ãã€ã³ããŒãããã ãã§OKããã¹ãçµäºæã«èªåçã«ã¹ãããã·ã§ãããååŸãããã // ❌ ééã import { takeArchive } from '@chromatic-com/playwright' ; // ✅ æ£ãã import { test , expect } from '@chromatic-com/playwright' ; 3. Storybookãã«ããšã©ãŒïŒwebpacké¢é£ïŒ Module not found: TypeError: Cannot read properties of undefined (reading 'tap') åå :  @chromatic-com/playwright ãå€ãStorybookããŒãžã§ã³ãèŠæ±ããããŒãžã§ã³ç«¶åãçºç 解決ç : Storybookã®ããŒãžã§ã³ã8.4.2ã«çµ±äž npm install @storybook/nextjs@8.4.2 @storybook/addon-essentials@8.4.2 \ @storybook/addon-interactions@8.4.2 @storybook/addon-links@8.4.2 \ @storybook/blocks@8.4.2 @storybook/react@8.4.2 @storybook/test@8.4.2 \ storybook@8.4.2 4. Chromaticã® storybookBuildDir ãå¿
èŠ CIã§Storybookããã«ãããŠããã¢ããããŒãããå Žåããã«ãæžã¿ãã£ã¬ã¯ããªãæå®ããå¿
èŠãããã - name : Build Storybook run : npm run build - storybook - name : Publish to Chromatic uses : chromaui/action@latest with : storybookBuildDir : storybook - static # ãããæå® ãŸãšã å°å
¥åŸã®ã¯ãŒã¯ãã㌠éçºè
ãPRãäœæ GitHub ActionsãStorybookããã«ã â Chromaticã«ã¢ããããŒã GitHub ActionsãE2Eãã¹ãå®è¡ â Chromaticã«ã¢ããããŒã PRã«Storybookãã¬ãã¥ãŒURLãèªåã³ã¡ã³ã ã¬ãã¥ã¢ãŒãChromaticã§å·®åãç¢ºèª åé¡ãªããã°ããŒãž â masterãã©ã³ãã¯èªåæ¿èª äœæãããã¡ã€ã«äžèЧ frontend/ âââ src/components/ â âââ ui/ â â âââ button.stories.tsx â â âââ card.stories.tsx â â âââ input.stories.tsx â â âââ textarea.stories.tsx â âââ chat/ â â âââ MessageList.stories.tsx â â âââ MessageInput.stories.tsx â â âââ ModeSelector.stories.tsx â âââ import/ â â âââ FileUploader.stories.tsx â â âââ ImportProgress.stories.tsx â âââ Introduction.mdx # ãã¶ã€ã³ã·ã¹ãã ããã¥ã¡ã³ã âââ e2e/ â âââ home.spec.ts # Chromaticå¯Ÿå¿æžã¿ âââ .storybook/ â âââ main.ts â âââ preview.ts âââ package.json .github/workflows/ âââ ci.yml # Chromaticèªååèšå® åŸããã广 Before After UIã¬ãã¥ãŒã¯æåã§åç»é¢ãç¢ºèª PRã«ãã¬ãã¥ãŒURLãèªåæçš¿ CSS倿Žã®åœ±é¿ç¯å²ãäžæ å
šã³ã³ããŒãã³ãã®ã¹ãããã·ã§ããã§å·®åæ€åº E2Eãã¹ãã¯çµæã®ã¿ç¢ºèª ç»é¢ãã£ããã£ãèªåã§æ¯èŒã»ç®¡ç ãã¶ã€ã³ã·ã¹ãã ã®ããã¥ã¡ã³ããé³è
å åžžã«ææ°ã®Storybookããã¹ãã£ã³ã° Chromaticãå°å
¥ããããšã§ãUIã®å質ãæ
ä¿ããªããéçºã¹ããŒããèœãšããªãã¯ãŒã¯ãããŒãå®çŸã§ããŸããã åèãªã³ã¯ Chromaticå
¬åŒããã¥ã¡ã³ã chromaui/action (GitHub Action) @chromatic-com/playwright Storybookå
¬åŒãµã€ã ã芧ããã ãããããšãããããŸãïŒ ãã®æçš¿ã¯ã圹ã«ç«ã¡ãŸãããïŒ åœ¹ã«ç«ã£ã 圹ã«ç«ããªãã£ã 0人ããã®æçš¿ã¯åœ¹ã«ç«ã£ããšèšã£ãŠããŸãã The post Next.js + Storybook + PlaywrightãChromaticã§ããžã¥ã¢ã«ãã¹ãèªååãã first appeared on SIOS Tech Lab .