From 637b06f248c4da6379936130efcd19057dc948fc Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Tue, 24 Feb 2026 18:18:10 +0100 Subject: [PATCH 1/3] test: create failing tests --- .../image-comparison-core/src/base.test.ts | 6 +++++ .../tests/storybook/launcher.test.ts | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/image-comparison-core/src/base.test.ts b/packages/image-comparison-core/src/base.test.ts index a81b3a12..be44a2db 100644 --- a/packages/image-comparison-core/src/base.test.ts +++ b/packages/image-comparison-core/src/base.test.ts @@ -53,4 +53,10 @@ describe('BaseClass', () => { expect(rmSync).toHaveBeenCalledTimes(2) }) + + it('should not clear runtime folders in the constructor - clearing should only happen once in the launcher (issue #683)', () => { + vi.mocked(rmSync).mockClear() + new BaseClass({ clearRuntimeFolder: true }) + expect(rmSync).not.toHaveBeenCalled() + }) }) diff --git a/packages/visual-service/tests/storybook/launcher.test.ts b/packages/visual-service/tests/storybook/launcher.test.ts index 25795799..4be4ebfa 100644 --- a/packages/visual-service/tests/storybook/launcher.test.ts +++ b/packages/visual-service/tests/storybook/launcher.test.ts @@ -1,4 +1,4 @@ -import { rmdirSync } from 'node:fs' +import { rmdirSync, rmSync } from 'node:fs' import { join } from 'node:path' import logger from '@wdio/logger' import type { Capabilities, Services } from '@wdio/types' @@ -25,6 +25,28 @@ vi.mock('../../src/storybook/utils.js', ()=>({ createStorybookCapabilities: vi.fn(), })) +describe('Visual Launcher - clearRuntimeFolder (issue #683)', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should clear runtime folders once in onPrepare, not per-worker', async () => { + vi.mocked(storybookUtils.isStorybookMode).mockReturnValueOnce(false) + const rmSyncMock = vi.mocked(rmSync) + rmSyncMock.mockClear() + + const launcher = new VisualLauncher({ clearRuntimeFolder: true }) + // Clear any calls that happened during construction — these are the bug + rmSyncMock.mockClear() + + const config = { runner: 'local', framework: 'mocha' } as WebdriverIO.Config + await launcher.onPrepare!(config, [{}]) + + // onPrepare should clear both the actual and diff folders + expect(rmSyncMock).toHaveBeenCalledTimes(2) + }) +}) + describe('Visual Launcher for Storybook', () => { describe('onPrepare', () => { let options: ClassOptions, From d22e30aea120335db3578314f799be7d162112fb Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Tue, 24 Feb 2026 18:37:54 +0100 Subject: [PATCH 2/3] fix: clear runtime folders once before workers start, not per spec (#683) Move clearRuntimeFolder logic from BaseClass constructor (runs per worker) to VisualLauncher.onPrepare (runs once in main process before workers). Refactor: extract storybook logic from launcher into storybook/hooks.ts and move the launcher out of the storybook/ directory to make it a general-purpose service launcher. Co-authored-by: Cursor --- .changeset/fix-clear-runtime-folder.md | 6 + .../image-comparison-core/src/base.test.ts | 9 -- packages/image-comparison-core/src/base.ts | 9 +- packages/visual-service/src/index.ts | 2 +- packages/visual-service/src/launcher.ts | 32 ++++ .../visual-service/src/storybook/hooks.ts | 129 +++++++++++++++ .../visual-service/src/storybook/launcher.ts | 147 ------------------ .../visual-service/tests/launcher.test.ts | 105 +++++++++++++ .../__snapshots__/hooks.test.ts.snap | 23 +++ .../__snapshots__/launcher.test.ts.snap | 23 --- .../{launcher.test.ts => hooks.test.ts} | 122 +++++---------- 11 files changed, 337 insertions(+), 270 deletions(-) create mode 100644 .changeset/fix-clear-runtime-folder.md create mode 100644 packages/visual-service/src/launcher.ts create mode 100644 packages/visual-service/src/storybook/hooks.ts delete mode 100644 packages/visual-service/src/storybook/launcher.ts create mode 100644 packages/visual-service/tests/launcher.test.ts create mode 100644 packages/visual-service/tests/storybook/__snapshots__/hooks.test.ts.snap delete mode 100644 packages/visual-service/tests/storybook/__snapshots__/launcher.test.ts.snap rename packages/visual-service/tests/storybook/{launcher.test.ts => hooks.test.ts} (65%) diff --git a/.changeset/fix-clear-runtime-folder.md b/.changeset/fix-clear-runtime-folder.md new file mode 100644 index 00000000..49fa00c8 --- /dev/null +++ b/.changeset/fix-clear-runtime-folder.md @@ -0,0 +1,6 @@ +--- +"@wdio/visual-service": patch +"@wdio/image-comparison-core": patch +--- + +Fix `clearRuntimeFolder` clearing the actual and diff folders after each spec/feature execution instead of once before all workers start. This caused only the last spec's visual data to be present in the output when running multiple specs. diff --git a/packages/image-comparison-core/src/base.test.ts b/packages/image-comparison-core/src/base.test.ts index be44a2db..878f0b95 100644 --- a/packages/image-comparison-core/src/base.test.ts +++ b/packages/image-comparison-core/src/base.test.ts @@ -45,15 +45,6 @@ describe('BaseClass', () => { expect(instance.folders.actualFolder).toContain('functional/screenshots') }) - it('clears runtime folders if clearRuntimeFolder is true', () => { - const options = { - clearRuntimeFolder: true, - } - new BaseClass(options) - - expect(rmSync).toHaveBeenCalledTimes(2) - }) - it('should not clear runtime folders in the constructor - clearing should only happen once in the launcher (issue #683)', () => { vi.mocked(rmSync).mockClear() new BaseClass({ clearRuntimeFolder: true }) diff --git a/packages/image-comparison-core/src/base.ts b/packages/image-comparison-core/src/base.ts index 4a965007..4a7a0fd4 100644 --- a/packages/image-comparison-core/src/base.ts +++ b/packages/image-comparison-core/src/base.ts @@ -18,11 +18,6 @@ export default class BaseClass { // Setup folder structure this.folders = this._setupFolders(options) - - // Clear runtime folders if requested - if (options.clearRuntimeFolder) { - this._clearRuntimeFolders() - } } /** @@ -47,9 +42,9 @@ export default class BaseClass { /** * Clear the runtime folders (actual and diff) - * @private + * @protected */ - private _clearRuntimeFolders(): void { + protected _clearRuntimeFolders(): void { log.info('\x1b[33m\n##############################\n!!CLEARING RUNTIME FOLDERS!!\n##############################\x1b[0m') try { diff --git a/packages/visual-service/src/index.ts b/packages/visual-service/src/index.ts index 3c49f887..de0a7a5e 100644 --- a/packages/visual-service/src/index.ts +++ b/packages/visual-service/src/index.ts @@ -1,6 +1,6 @@ import type { WicElement } from '@wdio/image-comparison-core' import WdioImageComparisonService from './service.js' -import VisualLauncher from './storybook/launcher.js' +import VisualLauncher from './launcher.js' import type { Output, Result, diff --git a/packages/visual-service/src/launcher.ts b/packages/visual-service/src/launcher.ts new file mode 100644 index 00000000..c5c3d048 --- /dev/null +++ b/packages/visual-service/src/launcher.ts @@ -0,0 +1,32 @@ +import type { Capabilities } from '@wdio/types' +import type { ClassOptions } from '@wdio/image-comparison-core' +import { BaseClass } from '@wdio/image-comparison-core' +import { prepareStorybook, cleanupStorybook } from './storybook/hooks.js' +import generateVisualReport from './reporter.js' + +export default class VisualLauncher extends BaseClass { + #options: ClassOptions + + constructor(options: ClassOptions) { + super(options) + this.#options = options + } + + async onPrepare(config: WebdriverIO.Config, capabilities: Capabilities.TestrunnerCapabilities) { + if (this.#options.clearRuntimeFolder) { + this._clearRuntimeFolders() + } + + await prepareStorybook(config, capabilities, this.#options, this.folders) + } + + async onComplete() { + cleanupStorybook() + + if (this.#options.createJsonReportFiles) { + new generateVisualReport( + { directoryPath: this.folders.actualFolder } + ).generate() + } + } +} diff --git a/packages/visual-service/src/storybook/hooks.ts b/packages/visual-service/src/storybook/hooks.ts new file mode 100644 index 00000000..ae4d6c71 --- /dev/null +++ b/packages/visual-service/src/storybook/hooks.ts @@ -0,0 +1,129 @@ +import { rmdirSync } from 'node:fs' +import logger from '@wdio/logger' +import { SevereServiceError } from 'webdriverio' +import type { Capabilities } from '@wdio/types' +import type { ClassOptions, CheckElementMethodOptions, Folders } from '@wdio/image-comparison-core' +import { + createStorybookCapabilities, + createTestFiles, + getArgvValue, + isCucumberFramework, + isStorybookMode, + parseSkipStories, + scanStorybook, +} from './utils.js' +import { CLIP_SELECTOR, NUM_SHARDS, V6_CLIP_SELECTOR } from '../constants.js' + +const log = logger('@wdio/visual-service') + +export async function prepareStorybook( + config: WebdriverIO.Config, + capabilities: Capabilities.TestrunnerCapabilities, + options: ClassOptions, + folders: Folders, +): Promise { + const isStorybook = isStorybookMode() + const framework = config.framework as string + const isCucumber = isCucumberFramework(framework) + + if (isCucumber && isStorybook) { + throw new SevereServiceError('\n\nRunning Storybook in combination with the cucumber framework adapter is not supported.\nOnly Jasmine and Mocha are supported.\n\n') + } + + if (!isStorybook) { + return + } + + log.info('Running `@wdio/visual-service` in Storybook mode.') + + const { storiesJson, storybookUrl, tempDir } = await scanStorybook(config, options) + process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER = tempDir + process.env.VISUAL_STORYBOOK_URL = storybookUrl + + if (typeof capabilities === 'object' && !Array.isArray(capabilities)) { + throw new SevereServiceError('\n\nRunning Storybook in combination with Multiremote is not supported.\nRemove your `capabilities` property from your config or assign an empty array to it like `capabilities: [],`.\n\n') + } + + capabilities.length = 0 + log.info('Clearing the current capabilities.') + + const compareOptions: CheckElementMethodOptions = { + blockOutSideBar: options.blockOutSideBar, + blockOutStatusBar: options.blockOutStatusBar, + blockOutToolBar: options.blockOutToolBar, + ignoreAlpha: options.ignoreAlpha, + ignoreAntialiasing: options.ignoreAntialiasing, + ignoreColors: options.ignoreColors, + ignoreLess: options.ignoreLess, + ignoreNothing: options.ignoreNothing, + rawMisMatchPercentage: options.rawMisMatchPercentage, + returnAllCompareData: options.returnAllCompareData, + saveAboveTolerance: options.saveAboveTolerance, + scaleImagesToSameSize: options.scaleImagesToSameSize, + } + + // --version + const versionOption = options?.storybook?.version + const versionArgv = getArgvValue('--version', value => Math.floor(parseFloat(value))) + const version = versionOption ?? versionArgv ?? 7 + // --numShards + const maxInstances = config?.maxInstances ?? 1 + const numShardsOption = options?.storybook?.numShards + const numShardsArgv = getArgvValue('--numShards', value => parseInt(value, 10)) + const numShards = Math.min(numShardsOption || numShardsArgv || NUM_SHARDS, maxInstances) + // --clip + const clipOption = options?.storybook?.clip + const clipArgv = getArgvValue('--clip', value => value !== 'false') + const clip = clipOption ?? clipArgv ?? true + // --clipSelector + const clipSelectorOption = options?.storybook?.clipSelector + const clipSelectorArgv = getArgvValue('--clipSelector', value => value) + const clipSelector = (clipSelectorOption ?? clipSelectorArgv) ?? (version === 6 ? V6_CLIP_SELECTOR : CLIP_SELECTOR) + process.env.VISUAL_STORYBOOK_CLIP_SELECTOR = clipSelector + // --skipStories + const skipStoriesOption = options?.storybook?.skipStories + const skipStoriesArgv = getArgvValue('--skipStories', value => value) + const skipStories = skipStoriesOption ?? skipStoriesArgv ?? [] + const parsedSkipStories = parseSkipStories(skipStories) + // --additionalSearchParams + const additionalSearchParamsOption = options?.storybook?.additionalSearchParams + const additionalSearchParamsArgv = getArgvValue('--additionalSearchParams', value => new URLSearchParams(value)) + const additionalSearchParams = additionalSearchParamsOption ?? additionalSearchParamsArgv ?? new URLSearchParams() + const getStoriesBaselinePath = options?.storybook?.getStoriesBaselinePath + + createTestFiles({ + additionalSearchParams, + clip, + clipSelector, + compareOptions, + directoryPath: tempDir, + folders, + framework, + getStoriesBaselinePath, + numShards, + skipStories: parsedSkipStories, + storiesJson, + storybookUrl, + }) + + createStorybookCapabilities(capabilities as WebdriverIO.Capabilities[]) +} + +export function cleanupStorybook(): void { + const tempDir = process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER + if (!tempDir) { + return + } + + log.info(`Cleaning up temporary folder for storybook specs: ${tempDir}`) + try { + rmdirSync(tempDir, { recursive: true }) + log.info(`Temporary folder for storybook specs has been removed: ${tempDir}`) + } catch (err) { + log.error(`Failed to remove temporary folder for storybook specs: ${tempDir} due to: ${(err as Error).message}`) + } + + delete process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER + delete process.env.VISUAL_STORYBOOK_URL + delete process.env.VISUAL_STORYBOOK_CLIP_SELECTOR +} diff --git a/packages/visual-service/src/storybook/launcher.ts b/packages/visual-service/src/storybook/launcher.ts deleted file mode 100644 index acb278e9..00000000 --- a/packages/visual-service/src/storybook/launcher.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { rmdirSync } from 'node:fs' -import logger from '@wdio/logger' -import { SevereServiceError } from 'webdriverio' -import type { Capabilities } from '@wdio/types' -import type { ClassOptions, CheckElementMethodOptions } from '@wdio/image-comparison-core' -import { BaseClass } from '@wdio/image-comparison-core' -import { - createStorybookCapabilities, - createTestFiles, - getArgvValue, - isCucumberFramework, - isStorybookMode, - parseSkipStories, - scanStorybook, -} from './utils.js' -import { CLIP_SELECTOR, NUM_SHARDS, V6_CLIP_SELECTOR } from '../constants.js' -import generateVisualReport from '../reporter.js' - -const log = logger('@wdio/visual-service') - -export default class VisualLauncher extends BaseClass { - #options: ClassOptions - - constructor(options: ClassOptions) { - super(options) - this.#options = options - } - - async onPrepare (config: WebdriverIO.Config, capabilities: Capabilities.TestrunnerCapabilities) { - const isStorybook = isStorybookMode() - const framework = config.framework as string - const isCucumber = isCucumberFramework(framework) - - if (isCucumber && isStorybook) { - throw new SevereServiceError('\n\nRunning Storybook in combination with the cucumber framework adapter is not supported.\nOnly Jasmine and Mocha are supported.\n\n') - } else if (isStorybook) { - log.info('Running `@wdio/visual-service` in Storybook mode.') - - const { storiesJson, storybookUrl, tempDir } = await scanStorybook(config, this.#options) - // Set an environment variable so it can be used in the onComplete hook - process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER = tempDir - // Add the storybook URL to the environment variables - process.env.VISUAL_STORYBOOK_URL = storybookUrl - - // Check the capabilities - // Multiremote capabilities are not supported - if (typeof capabilities === 'object' && !Array.isArray(capabilities)) { - throw new SevereServiceError('\n\nRunning Storybook in combination with Multiremote is not supported.\nRemove your `capabilities` property from your config or assign an empty array to it like `capabilities: [],`.\n\n') - } - - // Clear the capabilities - capabilities.length = 0 - log.info('Clearing the current capabilities.') - - // Get compare options from config - const compareOptions: CheckElementMethodOptions = { - blockOutSideBar: this.#options.blockOutSideBar, - blockOutStatusBar: this.#options.blockOutStatusBar, - blockOutToolBar: this.#options.blockOutToolBar, - ignoreAlpha: this.#options.ignoreAlpha, - ignoreAntialiasing: this.#options.ignoreAntialiasing, - ignoreColors: this.#options.ignoreColors, - ignoreLess: this.#options.ignoreLess, - ignoreNothing: this.#options.ignoreNothing, - rawMisMatchPercentage: this.#options.rawMisMatchPercentage, - returnAllCompareData: this.#options.returnAllCompareData, - saveAboveTolerance: this.#options.saveAboveTolerance, - scaleImagesToSameSize: this.#options.scaleImagesToSameSize, - } - - // Determine some run options - // --version - const versionOption = this.#options?.storybook?.version - const versionArgv = getArgvValue('--version', value => Math.floor(parseFloat(value))) - const version = versionOption ?? versionArgv ?? 7 - // --numShards - const maxInstances = config?.maxInstances ?? 1 - const numShardsOption = this.#options?.storybook?.numShards - const numShardsArgv = getArgvValue('--numShards', value => parseInt(value, 10)) - const numShards = Math.min(numShardsOption || numShardsArgv || NUM_SHARDS, maxInstances) - // --clip - const clipOption = this.#options?.storybook?.clip - const clipArgv = getArgvValue('--clip', value => value !== 'false') - const clip = clipOption ?? clipArgv ?? true - // --clipSelector - const clipSelectorOption = this.#options?.storybook?.clipSelector - const clipSelectorArgv = getArgvValue('--clipSelector', value => value) - // V6 has '#root' as the root element, V7 has '#storybook-root' - const clipSelector = (clipSelectorOption ?? clipSelectorArgv) ?? (version === 6 ? V6_CLIP_SELECTOR : CLIP_SELECTOR) - // Add the clip selector to the environment variables - process.env.VISUAL_STORYBOOK_CLIP_SELECTOR = clipSelector - // --skipStories - const skipStoriesOption = this.#options?.storybook?.skipStories - const skipStoriesArgv = getArgvValue('--skipStories', value => value) - const skipStories = skipStoriesOption ?? skipStoriesArgv ?? [] - const parsedSkipStories = parseSkipStories(skipStories) - // --additionalSearchParams - const additionalSearchParamsOption = this.#options?.storybook?.additionalSearchParams - const additionalSearchParamsArgv = getArgvValue('--additionalSearchParams', value => new URLSearchParams(value)) - const additionalSearchParams = additionalSearchParamsOption ?? additionalSearchParamsArgv ?? new URLSearchParams() - const getStoriesBaselinePath = this.#options?.storybook?.getStoriesBaselinePath - - // Create the test files - createTestFiles({ - additionalSearchParams, - clip, - clipSelector, - compareOptions, - directoryPath: tempDir, - folders: this.folders, - framework, - getStoriesBaselinePath, - numShards, - skipStories: parsedSkipStories, - storiesJson, - storybookUrl, - }) - - // Create the capabilities - createStorybookCapabilities(capabilities as WebdriverIO.Capabilities[]) - } - } - - async onComplete () { - const tempDir = process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER - if (tempDir) { - log.info(`Cleaning up temporary folder for storybook specs: ${tempDir}`) - try { - rmdirSync(tempDir, { recursive: true }) - log.info(`Temporary folder for storybook specs has been removed: ${tempDir}`) - } catch (err) { - log.error(`Failed to remove temporary folder for storybook specs: ${tempDir} due to: ${(err as Error).message}`) - } - - // Remove the environment variables - delete process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER - delete process.env.VISUAL_STORYBOOK_URL - delete process.env.VISUAL_STORYBOOK_CLIP_SELECTOR - } - - if (this.#options.createJsonReportFiles){ - new generateVisualReport( - { directoryPath: this.folders.actualFolder } - ).generate() - } - } -} diff --git a/packages/visual-service/tests/launcher.test.ts b/packages/visual-service/tests/launcher.test.ts new file mode 100644 index 00000000..c2cda7af --- /dev/null +++ b/packages/visual-service/tests/launcher.test.ts @@ -0,0 +1,105 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' +import type { Services } from '@wdio/types' +import { afterEach, describe, expect, it, vi } from 'vitest' +import VisualLauncher from '../src/launcher.js' +import * as storybookHooks from '../src/storybook/hooks.js' + +vi.mock('@wdio/logger', () => import(join(process.cwd(), '__mocks__', '@wdio/logger'))) +vi.mock('fs') + +vi.mock('../src/storybook/hooks.js', () => ({ + prepareStorybook: vi.fn(), + cleanupStorybook: vi.fn(), +})) + +vi.mock('../src/reporter.js', () => ({ + default: vi.fn().mockImplementation(() => ({ generate: vi.fn() })), +})) + +describe('VisualLauncher', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + describe('onPrepare - clearRuntimeFolder (issue #683)', () => { + it('should clear runtime folders once in onPrepare when clearRuntimeFolder is true', async () => { + const rmSyncMock = vi.mocked(rmSync) + rmSyncMock.mockClear() + + const launcher = new VisualLauncher({ clearRuntimeFolder: true }) + rmSyncMock.mockClear() + + const config = { runner: 'local', framework: 'mocha' } as WebdriverIO.Config + await launcher.onPrepare!(config, [{}]) + + expect(rmSyncMock).toHaveBeenCalledTimes(2) + }) + + it('should not clear runtime folders in onPrepare when clearRuntimeFolder is false', async () => { + const rmSyncMock = vi.mocked(rmSync) + rmSyncMock.mockClear() + + const launcher = new VisualLauncher({ clearRuntimeFolder: false }) + rmSyncMock.mockClear() + + const config = { runner: 'local', framework: 'mocha' } as WebdriverIO.Config + await launcher.onPrepare!(config, [{}]) + + expect(rmSyncMock).not.toHaveBeenCalled() + }) + }) + + describe('onPrepare - storybook delegation', () => { + it('should delegate to prepareStorybook', async () => { + const launcher = new VisualLauncher({}) + const config = { runner: 'local', framework: 'mocha' } as WebdriverIO.Config + const caps = [{}] + + await launcher.onPrepare!(config, caps) + + expect(vi.mocked(storybookHooks.prepareStorybook)).toHaveBeenCalledWith( + config, + caps, + expect.any(Object), + expect.objectContaining({ + actualFolder: expect.any(String), + baselineFolder: expect.any(String), + diffFolder: expect.any(String), + }), + ) + }) + }) + + describe('onComplete', () => { + it('should delegate to cleanupStorybook', async () => { + const launcher = new VisualLauncher({}) as Services.ServiceInstance + + // @ts-ignore + await launcher.onComplete!() + + expect(vi.mocked(storybookHooks.cleanupStorybook)).toHaveBeenCalledOnce() + }) + + it('should generate visual report when createJsonReportFiles is true', async () => { + const generateVisualReport = (await import('../src/reporter.js')).default + const launcher = new VisualLauncher({ createJsonReportFiles: true }) as Services.ServiceInstance + + // @ts-ignore + await launcher.onComplete!() + + expect(generateVisualReport).toHaveBeenCalled() + }) + + it('should not generate visual report when createJsonReportFiles is false', async () => { + const generateVisualReport = (await import('../src/reporter.js')).default + vi.mocked(generateVisualReport).mockClear() + const launcher = new VisualLauncher({ createJsonReportFiles: false }) as Services.ServiceInstance + + // @ts-ignore + await launcher.onComplete!() + + expect(generateVisualReport).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/visual-service/tests/storybook/__snapshots__/hooks.test.ts.snap b/packages/visual-service/tests/storybook/__snapshots__/hooks.test.ts.snap new file mode 100644 index 00000000..34c6b037 --- /dev/null +++ b/packages/visual-service/tests/storybook/__snapshots__/hooks.test.ts.snap @@ -0,0 +1,23 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Storybook hooks > prepareStorybook > should process all default data 1`] = `"Running \`@wdio/visual-service\` in Storybook mode."`; + +exports[`Storybook hooks > prepareStorybook > should process all default data 2`] = `"Clearing the current capabilities."`; + +exports[`Storybook hooks > prepareStorybook > should throw an error for storybook and cucumber 1`] = ` +" + +Running Storybook in combination with the cucumber framework adapter is not supported. +Only Jasmine and Mocha are supported. + +" +`; + +exports[`Storybook hooks > prepareStorybook > should throw an error for storybook multiremote 1`] = ` +" + +Running Storybook in combination with Multiremote is not supported. +Remove your \`capabilities\` property from your config or assign an empty array to it like \`capabilities: [],\`. + +" +`; diff --git a/packages/visual-service/tests/storybook/__snapshots__/launcher.test.ts.snap b/packages/visual-service/tests/storybook/__snapshots__/launcher.test.ts.snap deleted file mode 100644 index c3f66b17..00000000 --- a/packages/visual-service/tests/storybook/__snapshots__/launcher.test.ts.snap +++ /dev/null @@ -1,23 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Visual Launcher for Storybook > onPrepare > should process all default data 1`] = `"Running \`@wdio/visual-service\` in Storybook mode."`; - -exports[`Visual Launcher for Storybook > onPrepare > should process all default data 2`] = `"Clearing the current capabilities."`; - -exports[`Visual Launcher for Storybook > onPrepare > should throw an error for storybook and cucumber 1`] = ` -" - -Running Storybook in combination with the cucumber framework adapter is not supported. -Only Jasmine and Mocha are supported. - -" -`; - -exports[`Visual Launcher for Storybook > onPrepare > should throw an error for storybook multiremote 1`] = ` -" - -Running Storybook in combination with Multiremote is not supported. -Remove your \`capabilities\` property from your config or assign an empty array to it like \`capabilities: [],\`. - -" -`; diff --git a/packages/visual-service/tests/storybook/launcher.test.ts b/packages/visual-service/tests/storybook/hooks.test.ts similarity index 65% rename from packages/visual-service/tests/storybook/launcher.test.ts rename to packages/visual-service/tests/storybook/hooks.test.ts index 4be4ebfa..6c04769f 100644 --- a/packages/visual-service/tests/storybook/launcher.test.ts +++ b/packages/visual-service/tests/storybook/hooks.test.ts @@ -1,17 +1,17 @@ -import { rmdirSync, rmSync } from 'node:fs' +import { rmdirSync } from 'node:fs' import { join } from 'node:path' import logger from '@wdio/logger' -import type { Capabilities, Services } from '@wdio/types' +import type { Capabilities } from '@wdio/types' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import VisualLauncher from '../../src/storybook/launcher.js' -import type { ClassOptions } from '@wdio/image-comparison-core' +import type { ClassOptions, Folders } from '@wdio/image-comparison-core' +import { prepareStorybook, cleanupStorybook } from '../../src/storybook/hooks.js' import * as storybookUtils from '../../src/storybook/utils.js' const log = logger('test') vi.mock('@wdio/logger', () => import(join(process.cwd(), '__mocks__', '@wdio/logger'))) vi.mock('fs') -vi.mock('../../src/storybook/utils.js', ()=>({ +vi.mock('../../src/storybook/utils.js', () => ({ isStorybookMode: vi.fn(() => true), isCucumberFramework: vi.fn(() => false), scanStorybook: vi.fn(() => ({ @@ -25,34 +25,12 @@ vi.mock('../../src/storybook/utils.js', ()=>({ createStorybookCapabilities: vi.fn(), })) -describe('Visual Launcher - clearRuntimeFolder (issue #683)', () => { - afterEach(() => { - vi.restoreAllMocks() - }) - - it('should clear runtime folders once in onPrepare, not per-worker', async () => { - vi.mocked(storybookUtils.isStorybookMode).mockReturnValueOnce(false) - const rmSyncMock = vi.mocked(rmSync) - rmSyncMock.mockClear() - - const launcher = new VisualLauncher({ clearRuntimeFolder: true }) - // Clear any calls that happened during construction — these are the bug - rmSyncMock.mockClear() - - const config = { runner: 'local', framework: 'mocha' } as WebdriverIO.Config - await launcher.onPrepare!(config, [{}]) - - // onPrepare should clear both the actual and diff folders - expect(rmSyncMock).toHaveBeenCalledTimes(2) - }) -}) - -describe('Visual Launcher for Storybook', () => { - describe('onPrepare', () => { - let options: ClassOptions, - caps: Capabilities.RequestedStandaloneCapabilities[], - config: WebdriverIO.Config, - Launcher: Services.ServiceInstance +describe('Storybook hooks', () => { + describe('prepareStorybook', () => { + let options: ClassOptions + let caps: Capabilities.RequestedStandaloneCapabilities[] + let config: WebdriverIO.Config + let folders: Folders beforeEach(() => { options = {} @@ -61,19 +39,19 @@ describe('Visual Launcher for Storybook', () => { runner: 'local', framework: 'mocha' } as WebdriverIO.Config - Launcher = new VisualLauncher(options) + folders = { + actualFolder: '.tmp/actual', + baselineFolder: '.tmp/baseline', + diffFolder: '.tmp/diff', + } vi.clearAllMocks() }) it('should process all default data', async () => { - if (!Launcher.onPrepare) { - throw new Error('onPrepare method is not defined on Launcher') - } - const logInfoMock = vi.spyOn(log, 'info') - await Launcher!.onPrepare(config, caps) + await prepareStorybook(config, caps, options, folders) expect(vi.mocked(storybookUtils.isStorybookMode)).toHaveBeenCalledOnce() expect(vi.mocked(storybookUtils.isCucumberFramework)).toHaveBeenCalledOnce() @@ -86,9 +64,6 @@ describe('Visual Launcher for Storybook', () => { }) it('should process all process.argv data', async () => { - if (!Launcher.onPrepare) { - throw new Error('onPrepare method is not defined on Launcher') - } vi.mocked(storybookUtils.getArgvValue) .mockReturnValueOnce(6) // --version .mockReturnValueOnce(2) // --numShards @@ -97,36 +72,28 @@ describe('Visual Launcher for Storybook', () => { .mockReturnValueOnce(['foo-bar-foo']) // --skipStories .mockReturnValueOnce('foo=bar') // --additionalSearchParams - await Launcher.onPrepare(config, caps) + await prepareStorybook(config, caps, options, folders) expect(vi.mocked(storybookUtils.getArgvValue)).toHaveBeenCalledTimes(6) expect(vi.mocked(storybookUtils.parseSkipStories)).toHaveBeenCalledWith(['foo-bar-foo']) }) it('should process all options data', async () => { - if (!Launcher.onPrepare) { - throw new Error('onPrepare method is not defined on Launcher') - } - options.storybook = { version: 7, numShards: 16, clip: false, clipSelector: 'clipSelector', skipStories: 'skipStories', additionalSearchParams: new URLSearchParams({ foo: 'bar' }) } - await Launcher.onPrepare(config, caps) + await prepareStorybook(config, caps, options, folders) expect(vi.mocked(storybookUtils.getArgvValue)).toHaveBeenCalledTimes(6) expect(vi.mocked(storybookUtils.parseSkipStories)).toHaveBeenCalledWith('skipStories') }) it('should throw an error for storybook and cucumber', async () => { - if (!Launcher.onPrepare) { - throw new Error('onPrepare method is not defined on Launcher') - } - const logInfoMock = vi.spyOn(log, 'info') vi.mocked(storybookUtils.isCucumberFramework).mockReturnValueOnce(true) let error try { - await Launcher.onPrepare(config, caps) + await prepareStorybook(config, caps, options, folders) } catch (e) { error = e } @@ -136,7 +103,6 @@ describe('Visual Launcher for Storybook', () => { expect(vi.mocked(storybookUtils.isStorybookMode)).toHaveBeenCalledOnce() expect(vi.mocked(storybookUtils.isCucumberFramework)).toHaveBeenCalledOnce() expect(logInfoMock).not.toHaveBeenCalled() - // Ensure other mocks were not called expect(vi.mocked(storybookUtils.getArgvValue)).not.toHaveBeenCalled() expect(vi.mocked(storybookUtils.parseSkipStories)).not.toHaveBeenCalled() expect(vi.mocked(storybookUtils.createTestFiles)).not.toHaveBeenCalled() @@ -144,10 +110,6 @@ describe('Visual Launcher for Storybook', () => { }) it('should throw an error for storybook multiremote', async () => { - if (!Launcher.onPrepare) { - throw new Error('onPrepare method is not defined on Launcher') - } - const logInfoMock = vi.spyOn(log, 'info') const multiremoteCaps = { myChromeBrowser: { @@ -161,9 +123,10 @@ describe('Visual Launcher for Storybook', () => { } } } + let error try { - await Launcher.onPrepare(config, multiremoteCaps) + await prepareStorybook(config, multiremoteCaps, options, folders) } catch (e) { error = e } @@ -171,68 +134,61 @@ describe('Visual Launcher for Storybook', () => { expect(error).toBeDefined() expect((error as Error).message).toMatchSnapshot() expect(logInfoMock).toHaveBeenCalledOnce() - // Ensure other mocks were not called expect(vi.mocked(storybookUtils.getArgvValue)).not.toHaveBeenCalled() expect(vi.mocked(storybookUtils.parseSkipStories)).not.toHaveBeenCalled() expect(vi.mocked(storybookUtils.createTestFiles)).not.toHaveBeenCalled() expect(vi.mocked(storybookUtils.createStorybookCapabilities)).not.toHaveBeenCalled() }) - }) - describe('onComplete', () => { - let Launcher: Services.ServiceInstance + it('should do nothing when not in storybook mode', async () => { + vi.mocked(storybookUtils.isStorybookMode).mockReturnValueOnce(false) + + await prepareStorybook(config, caps, options, folders) + + expect(vi.mocked(storybookUtils.scanStorybook)).not.toHaveBeenCalled() + expect(vi.mocked(storybookUtils.createTestFiles)).not.toHaveBeenCalled() + expect(vi.mocked(storybookUtils.createStorybookCapabilities)).not.toHaveBeenCalled() + }) + }) + describe('cleanupStorybook', () => { beforeEach(() => { vi.clearAllMocks() - Launcher = new VisualLauncher({}) }) afterEach(() => { - // Clean up environment variables delete process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER }) - it('should remove temporary folder and log success', async () => { - if (!Launcher.onComplete) { - throw new Error('onComplete method is not defined on Launcher') - } + it('should remove temporary folder and log success', () => { process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER = 'path/to/tempDir' const logInfoSpy = vi.spyOn(log, 'info') const rmdirSyncMock = vi.mocked(rmdirSync) - // @ts-ignore - await Launcher.onComplete() + cleanupStorybook() expect(rmdirSyncMock).toHaveBeenCalledWith('path/to/tempDir', { recursive: true }) expect(logInfoSpy).toHaveBeenCalledWith(expect.stringContaining('Temporary folder for storybook specs has been removed')) expect(process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER).toBeUndefined() }) - it('should log error if temporary folder removal fails', async () => { - if (!Launcher.onComplete) { - throw new Error('onComplete method is not defined on Launcher') - } + it('should log error if temporary folder removal fails', () => { process.env.VISUAL_STORYBOOK_TEMP_SPEC_FOLDER = 'path/to/tempDir' const logErrorSpy = vi.spyOn(log, 'error') vi.mocked(rmdirSync).mockImplementationOnce(() => { throw new Error('Deletion Failed') }) - // @ts-ignore - await Launcher.onComplete() + cleanupStorybook() expect(logErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to remove temporary folder for storybook specs')) }) - it('should do nothing if temp directory is not set', async () => { - if (!Launcher.onComplete) { - throw new Error('onComplete method is not defined on Launcher') - } + it('should do nothing if temp directory is not set', () => { const rmdirSyncMock = vi.mocked(rmdirSync) const logInfoSpy = vi.spyOn(log, 'info') - // @ts-ignore - await Launcher.onComplete() + cleanupStorybook() expect(rmdirSyncMock).not.toHaveBeenCalled() expect(logInfoSpy).not.toHaveBeenCalled() From a9ae286dbbab88c162a0785b8db5709567b86bf7 Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Tue, 24 Feb 2026 18:40:32 +0100 Subject: [PATCH 3/3] chore: add committer to changeset Co-authored-by: Cursor --- .changeset/fix-clear-runtime-folder.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.changeset/fix-clear-runtime-folder.md b/.changeset/fix-clear-runtime-folder.md index 49fa00c8..b9e26038 100644 --- a/.changeset/fix-clear-runtime-folder.md +++ b/.changeset/fix-clear-runtime-folder.md @@ -4,3 +4,7 @@ --- Fix `clearRuntimeFolder` clearing the actual and diff folders after each spec/feature execution instead of once before all workers start. This caused only the last spec's visual data to be present in the output when running multiple specs. + +# Committers: 1 + +- Wim Selles ([@wswebcreation](https://github.com/wswebcreation))