diff --git a/code/frameworks/angular/src/builders/start-storybook/index.ts b/code/frameworks/angular/src/builders/start-storybook/index.ts index 3b44e0dea51f..642953dc4366 100644 --- a/code/frameworks/angular/src/builders/start-storybook/index.ts +++ b/code/frameworks/angular/src/builders/start-storybook/index.ts @@ -49,6 +49,7 @@ export type StorybookBuilderOptions = JsonObject & { | 'ci' | 'quiet' | 'disableTelemetry' + | 'initialPath' | 'open' | 'docs' >; @@ -98,6 +99,7 @@ const commandBuilder: BuilderHandlerFn = (options, cont sslKey, disableTelemetry, assets, + initialPath, open, } = options; @@ -123,6 +125,7 @@ const commandBuilder: BuilderHandlerFn = (options, cont ...(assets ? { assets } : {}), }, tsConfig, + initialPath, open, }; diff --git a/code/frameworks/angular/src/builders/start-storybook/schema.json b/code/frameworks/angular/src/builders/start-storybook/schema.json index 764143407b2d..d10d07d95d5d 100644 --- a/code/frameworks/angular/src/builders/start-storybook/schema.json +++ b/code/frameworks/angular/src/builders/start-storybook/schema.json @@ -114,6 +114,10 @@ "items": { "$ref": "#/definitions/assetPattern" } + }, + "initialPath": { + "type": "string", + "description": "URL path to be appended when visiting Storybook for the first time" } }, "additionalProperties": false, diff --git a/code/lib/cli/src/dev.ts b/code/lib/cli/src/dev.ts index be90c298d68d..216a15595a73 100644 --- a/code/lib/cli/src/dev.ts +++ b/code/lib/cli/src/dev.ts @@ -3,6 +3,7 @@ import { sync as readUpSync } from 'read-pkg-up'; import { logger, instance as npmLog } from '@storybook/node-logger'; import { buildDevStandalone, withTelemetry } from '@storybook/core-server'; import { cache } from '@storybook/core-common'; +import type { CLIOptions } from '@storybook/types'; function printError(error: any) { // this is a weird bugfix, somehow 'node-pre-gyp' is polluting the npmLog header @@ -35,7 +36,7 @@ function printError(error: any) { logger.line(); } -export const dev = async (cliOptions: any) => { +export const dev = async (cliOptions: CLIOptions) => { process.env.NODE_ENV = process.env.NODE_ENV || 'development'; const options = { @@ -45,8 +46,15 @@ export const dev = async (cliOptions: any) => { ignorePreview: !!cliOptions.previewUrl && !cliOptions.forceBuildPreview, cache, packageJson: readUpSync({ cwd: __dirname }).packageJson, - }; - await withTelemetry('dev', { cliOptions, presetOptions: options, printError }, () => - buildDevStandalone(options) + } as Parameters[0]; + + await withTelemetry( + 'dev', + { + cliOptions, + presetOptions: options as Parameters[1]['presetOptions'], + printError, + }, + () => buildDevStandalone(options) ); }; diff --git a/code/lib/cli/src/generate.ts b/code/lib/cli/src/generate.ts index 6b71d0d0464f..0a3ea06f54c5 100644 --- a/code/lib/cli/src/generate.ts +++ b/code/lib/cli/src/generate.ts @@ -216,6 +216,10 @@ command('dev') ) .option('--force-build-preview', 'Build the preview iframe even if you are using --preview-url') .option('--docs', 'Build a documentation-only site using addon-docs') + .option( + '--initial-path [path]', + 'URL path to be appended when visiting Storybook for the first time' + ) .action(async (options) => { logger.setLevel(program.loglevel); consoleLogger.log(chalk.bold(`${pkg.name} v${pkg.version}`) + chalk.reset('\n')); diff --git a/code/lib/cli/src/initiate.ts b/code/lib/cli/src/initiate.ts index 6d62f6061ca7..0f74d3c21609 100644 --- a/code/lib/cli/src/initiate.ts +++ b/code/lib/cli/src/initiate.ts @@ -5,6 +5,7 @@ import { telemetry } from '@storybook/telemetry'; import { withTelemetry } from '@storybook/core-server'; import dedent from 'ts-dedent'; +import boxen from 'boxen'; import { installableProjectTypes, ProjectType } from './project_types'; import { detect, @@ -37,6 +38,7 @@ import { JsPackageManagerFactory, useNpmWarning } from './js-package-manager'; import type { NpmOptions } from './NpmOptions'; import type { CommandOptions } from './generators/types'; import { HandledError } from './HandledError'; +import { dev } from './dev'; const logger = console; @@ -256,7 +258,7 @@ async function doInitiate(options: CommandOptions, pkg: PackageJson): Promise { diff --git a/code/lib/core-server/src/dev-server.ts b/code/lib/core-server/src/dev-server.ts index a66de7bb5a89..c9a1f814417f 100644 --- a/code/lib/core-server/src/dev-server.ts +++ b/code/lib/core-server/src/dev-server.ts @@ -55,9 +55,9 @@ export async function storybookDevServer(options: Options) { app.use(router); - const { port, host } = options; + const { port, host, initialPath } = options; const proto = options.https ? 'https' : 'http'; - const { address, networkAddress } = getServerAddresses(port, host, proto); + const { address, networkAddress } = getServerAddresses(port, host, proto, initialPath); const listening = new Promise((resolve, reject) => { // @ts-expect-error (Following line doesn't match TypeScript signature at all 🤔) diff --git a/code/lib/core-server/src/utils/server-address.test.ts b/code/lib/core-server/src/utils/server-address.test.ts new file mode 100644 index 000000000000..bf7dbd194fcf --- /dev/null +++ b/code/lib/core-server/src/utils/server-address.test.ts @@ -0,0 +1,81 @@ +import detectPort from 'detect-port'; +import { getServerAddresses, getServerPort, getServerChannelUrl } from './server-address'; + +jest.mock('ip'); +jest.mock('detect-port'); +jest.mock('@storybook/node-logger'); + +describe('getServerAddresses', () => { + const port = 3000; + const host = 'localhost'; + const proto = 'http'; + + it('should return server addresses without initial path by default', () => { + const expectedAddress = `${proto}://localhost:${port}/`; + const expectedNetworkAddress = `${proto}://${host}:${port}/`; + + const result = getServerAddresses(port, host, proto); + + expect(result.address).toBe(expectedAddress); + expect(result.networkAddress).toBe(expectedNetworkAddress); + }); + + it('should return server addresses with initial path', () => { + const initialPath = '/foo/bar'; + + const expectedAddress = `${proto}://localhost:${port}/?path=/foo/bar`; + const expectedNetworkAddress = `${proto}://${host}:${port}/?path=/foo/bar`; + + const result = getServerAddresses(port, host, proto, initialPath); + + expect(result.address).toBe(expectedAddress); + expect(result.networkAddress).toBe(expectedNetworkAddress); + }); + + it('should return server addresses with initial path and add slash if missing', () => { + const initialPath = 'foo/bar'; + + const expectedAddress = `${proto}://localhost:${port}/?path=/foo/bar`; + const expectedNetworkAddress = `${proto}://${host}:${port}/?path=/foo/bar`; + + const result = getServerAddresses(port, host, proto, initialPath); + + expect(result.address).toBe(expectedAddress); + expect(result.networkAddress).toBe(expectedNetworkAddress); + }); +}); + +describe('getServerPort', () => { + const port = 3000; + + it('should resolve with a free port', async () => { + const expectedFreePort = 4000; + + (detectPort as jest.Mock).mockResolvedValue(expectedFreePort); + + const result = await getServerPort(port); + + expect(result).toBe(expectedFreePort); + }); +}); + +describe('getServerChannelUrl', () => { + const port = 3000; + it('should return WebSocket URL with HTTP', () => { + const options = { https: false }; + const expectedUrl = `ws://localhost:${port}/storybook-server-channel`; + + const result = getServerChannelUrl(port, options); + + expect(result).toBe(expectedUrl); + }); + + it('should return WebSocket URL with HTTPS', () => { + const options = { https: true }; + const expectedUrl = `wss://localhost:${port}/storybook-server-channel`; + + const result = getServerChannelUrl(port, options); + + expect(result).toBe(expectedUrl); + }); +}); diff --git a/code/lib/core-server/src/utils/server-address.ts b/code/lib/core-server/src/utils/server-address.ts index 54b57b09202d..3332fa53b0d3 100644 --- a/code/lib/core-server/src/utils/server-address.ts +++ b/code/lib/core-server/src/utils/server-address.ts @@ -3,10 +3,26 @@ import ip from 'ip'; import { logger } from '@storybook/node-logger'; import detectFreePort from 'detect-port'; -export function getServerAddresses(port: number, host: string, proto: string) { +export function getServerAddresses( + port: number, + host: string, + proto: string, + initialPath?: string +) { + const address = new URL(`${proto}://localhost:${port}/`); + const networkAddress = new URL(`${proto}://${host || ip.address()}:${port}/`); + + if (initialPath) { + const searchParams = `?path=${decodeURIComponent( + initialPath.startsWith('/') ? initialPath : `/${initialPath}` + )}`; + address.search = searchParams; + networkAddress.search = searchParams; + } + return { - address: `${proto}://localhost:${port}/`, - networkAddress: `${proto}://${host || ip.address()}:${port}/`, + address: address.href, + networkAddress: networkAddress.href, }; } diff --git a/code/lib/types/src/modules/core-common.ts b/code/lib/types/src/modules/core-common.ts index caaa96db041c..275f1422347d 100644 --- a/code/lib/types/src/modules/core-common.ts +++ b/code/lib/types/src/modules/core-common.ts @@ -139,6 +139,7 @@ export interface CLIOptions { disableTelemetry?: boolean; enableCrashReports?: boolean; host?: string; + initialPath?: string; /** * @deprecated Use 'staticDirs' Storybook Configuration option instead */ diff --git a/scripts/task.ts b/scripts/task.ts index 585c65dd84cf..4cb138f72862 100644 --- a/scripts/task.ts +++ b/scripts/task.ts @@ -325,6 +325,9 @@ async function runTask(task: Task, details: TemplateDetails, optionValues: Passe const controllers: AbortController[] = []; async function run() { + // useful for other scripts to know whether they're running in the creation of a sandbox in the monorepo + process.env.IN_STORYBOOK_SANDBOX = 'true'; + const allOptionValues = await getOptionsOrPrompt('yarn task', options); const { task: taskKey, startFrom, junit, ...optionValues } = allOptionValues; diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index cd15ac2c81ec..4f75a49037f2 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -24,6 +24,7 @@ import { } from '../utils/yarn'; import { exec } from '../utils/exec'; import type { ConfigFile } from '../../code/lib/csf-tools'; +import storybookPackages from '../../code/lib/cli/src/versions'; import { writeConfig } from '../../code/lib/csf-tools'; import { filterExistsInCodeDir } from '../utils/filterExistsInCodeDir'; import { findFirstPath } from '../utils/paths'; @@ -430,8 +431,6 @@ export const addStories: Task['run'] = async ( }); } - console.log({ sandboxSpecificStoriesFolder, storiesVariantFolder }); - if ( await pathExists( resolve(CODE_DIRECTORY, frameworkPath, join('template', storiesVariantFolder)) @@ -473,9 +472,12 @@ export const addStories: Task['run'] = async ( ); const addonDirs = await Promise.all( - [...mainAddons, ...extraAddons].map(async (addon) => - workspacePath('addon', `@storybook/addon-${addon}`) - ) + [...mainAddons, ...extraAddons] + // only include addons that are in the monorepo + .filter((addon: string) => + Object.keys(storybookPackages).find((pkg: string) => pkg === `@storybook/addon-${addon}`) + ) + .map(async (addon) => workspacePath('addon', `@storybook/addon-${addon}`)) ); if (isCoreRenderer) {