Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[typescriptreact]": {
Expand Down
3 changes: 2 additions & 1 deletion code/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,8 @@
"typescript": "^5.8.3",
"unique-string": "^3.0.0",
"use-resize-observer": "^9.1.0",
"watchpack": "^2.2.0"
"watchpack": "^2.2.0",
"zod": "^3.24.1"
},
"peerDependencies": {
"prettier": "^2 || ^3"
Expand Down
10 changes: 9 additions & 1 deletion code/core/src/cli/bin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { version } from '../../../package.json';
import { build } from '../build';
import { buildIndex as index } from '../buildIndex';
import { dev } from '../dev';
import { globalSettings } from '../globalSettings';

addToGlobalContext('cliVersion', versions.storybook);

Expand All @@ -27,7 +28,14 @@ const command = (name: string) =>
process.env.STORYBOOK_DISABLE_TELEMETRY && process.env.STORYBOOK_DISABLE_TELEMETRY !== 'false'
)
.option('--debug', 'Get more logs in debug mode', false)
.option('--enable-crash-reports', 'Enable sending crash reports to telemetry data');
.option('--enable-crash-reports', 'Enable sending crash reports to telemetry data')
.hook('preAction', async () => {
try {
await globalSettings();
} catch (e) {
consoleLogger.error('Error loading global settings', e);
}
});

command('dev')
.option('-p, --port <number>', 'Port to run Storybook', (str) => parseInt(str, 10))
Expand Down
100 changes: 100 additions & 0 deletions code/core/src/cli/globalSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { afterEach } from 'node:test';

import { beforeEach, describe, expect, it, vi } from 'vitest';

import { type Settings, _clearGlobalSettings, globalSettings } from './globalSettings';

vi.mock('node:fs');
vi.mock('node:fs/promises');

const userSince = new Date();
const baseSettings = { version: 1, userSince: +userSince };
const baseSettingsJson = JSON.stringify(baseSettings, null, 2);

const TEST_SETTINGS_FILE = '/test/settings.json';

beforeEach(() => {
_clearGlobalSettings();

vi.useFakeTimers();
vi.setSystemTime(userSince);

vi.resetAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});

describe('globalSettings', () => {
it('loads settings when called for the first time', async () => {
vi.mocked(fs.readFile).mockResolvedValue(baseSettingsJson);

const settings = await globalSettings(TEST_SETTINGS_FILE);

expect(settings.value.userSince).toBe(+userSince);
});

it('does nothing if settings are already loaded', async () => {
vi.mocked(fs.readFile).mockResolvedValue(baseSettingsJson);
await globalSettings(TEST_SETTINGS_FILE);

vi.mocked(fs.readFile).mockClear();
await globalSettings(TEST_SETTINGS_FILE);
expect(fs.readFile).not.toHaveBeenCalled();
});

it('does not save settings if they exist', async () => {
vi.mocked(fs.readFile).mockResolvedValue(baseSettingsJson);

await globalSettings(TEST_SETTINGS_FILE);

expect(fs.writeFile).not.toHaveBeenCalled();
});

it('saves settings and creates directory if they do not exist', async () => {
const error = new Error() as Error & { code: string };
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);

await globalSettings(TEST_SETTINGS_FILE);

expect(fs.mkdir).toHaveBeenCalledWith(dirname(TEST_SETTINGS_FILE), { recursive: true });
expect(fs.writeFile).toHaveBeenCalledWith(TEST_SETTINGS_FILE, baseSettingsJson);
});
});

describe('Settings', () => {
let settings: Settings;
beforeEach(async () => {
vi.mocked(fs.readFile).mockResolvedValue(baseSettingsJson);

settings = await globalSettings(TEST_SETTINGS_FILE);
});

describe('save', () => {
it('overwrites existing settings', async () => {
settings.value.init = { skipOnboarding: true };
await settings.save();

expect(fs.writeFile).toHaveBeenCalledWith(
TEST_SETTINGS_FILE,
JSON.stringify({ ...baseSettings, init: { skipOnboarding: true } }, null, 2)
);
});

it('throws error if write fails', async () => {
vi.mocked(fs.writeFile).mockRejectedValue(new Error('Write error'));

await expect(settings.save()).rejects.toThrow('Unable to save global settings');
});

it('throws error if directory creation fails', async () => {
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Directory creation error'));

await expect(settings.save()).rejects.toThrow('Unable to save global settings');
});
});
});
80 changes: 80 additions & 0 deletions code/core/src/cli/globalSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';

import { z } from 'zod';

import { SavingGlobalSettingsFileError } from '../server-errors';

const DEFAULT_SETTINGS_PATH = join(homedir(), '.storybook', 'settings.json');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I foresee issues if we ever use findUp for .storybook in different parts of the CLI and end up finding this package as if it is a config dir. We could be aware of this and try to always limit findUp to only go up until the gitRoot


const VERSION = 1;

const userSettingSchema = z.object({
version: z.number(),
// NOTE: every key (and subkey) below must be optional, for forwards compatibility reasons
// (we can remove keys once they are deprecated)
userSince: z.number().optional(),
init: z.object({ skipOnboarding: z.boolean().optional() }).optional(),
});

let settings: Settings | undefined;
export async function globalSettings(filePath = DEFAULT_SETTINGS_PATH) {
if (settings) {
return settings;
}

try {
const content = await fs.readFile(filePath, 'utf8');
const settingsValue = userSettingSchema.parse(JSON.parse(content));
settings = new Settings(filePath, settingsValue);
} catch (err: any) {
// We don't currently log the issue we have loading the setting file here, but if it doesn't
// yet exist we'll get err.code = 'ENOENT'

// There is no existing settings file or it has a problem;
settings = new Settings(filePath, { version: VERSION, userSince: Date.now() });
await settings.save();
}

return settings;
}

// For testing
export function _clearGlobalSettings() {
settings = undefined;
}

/**
* A class for reading and writing settings from a JSON file. Supports nested settings with dot
* notation.
*/
export class Settings {
private filePath: string;

public value: z.infer<typeof userSettingSchema>;

/**
* Create a new Settings instance
*
* @param filePath Path to the JSON settings file
* @param value Loaded value of settings
*/
constructor(filePath: string, value: z.infer<typeof userSettingSchema>) {
this.filePath = filePath;
this.value = value;
}

/** Save settings to the file */
async save(): Promise<void> {
try {
await fs.mkdir(dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(this.value, null, 2));
} catch (err) {
throw new SavingGlobalSettingsFileError({
filePath: this.filePath,
error: err,
});
}
}
}
1 change: 1 addition & 0 deletions code/core/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './dirs';
export * from './project_types';
export * from './NpmOptions';
export * from './eslintPlugin';
export * from './globalSettings';
2 changes: 1 addition & 1 deletion code/core/src/common/utils/notify-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const notifyTelemetry = async () => {
);
logger.log(`This information is used to shape Storybook's roadmap and prioritize features.`);
logger.log(
`You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:`
`You can learn more, including how to opt-out of this anonymous program, by visiting:`
);
logger.log(picocolors.cyan('https://storybook.js.org/telemetry'));
logger.log();
Expand Down
12 changes: 12 additions & 0 deletions code/core/src/server-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,3 +602,15 @@ export class IncompatiblePostCssConfigError extends StorybookError {
});
}
}

export class SavingGlobalSettingsFileError extends StorybookError {
constructor(public data: { filePath: string; error: Error | unknown }) {
super({
category: Category.CORE_SERVER,
code: 1,
message: dedent`
Unable to save global settings file to ${data.filePath}
${data.error && `Reason: ${data.error}`}`,
});
}
}
10 changes: 10 additions & 0 deletions code/core/src/telemetry/storybook-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,5 +420,15 @@ describe('storybook-metadata', () => {
});
}
);

it('should detect userSince info', async () => {
const res = await computeStorybookMetadata({
packageJson: packageJsonMock,
packageJsonPath,
mainConfig: mainJsMock,
});

expect(res.userSince).toBeDefined();
});
});
});
3 changes: 3 additions & 0 deletions code/core/src/telemetry/storybook-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { PackageJson, StorybookConfig } from 'storybook/internal/types';
import { findPackage, findPackagePath } from 'fd-package-json';
import { detect } from 'package-manager-detector';

import { globalSettings } from '../cli/globalSettings';
import { getApplicationFileCount } from './get-application-file-count';
import { getChromaticVersionSpecifier } from './get-chromatic-version';
import { getFrameworkInfo } from './get-framework-info';
Expand Down Expand Up @@ -53,8 +54,10 @@ export const computeStorybookMetadata = async ({
packageJson: PackageJson;
mainConfig?: StorybookConfig & Record<string, any>;
}): Promise<StorybookMetadata> => {
const settings = await globalSettings();
const metadata: Partial<StorybookMetadata> = {
generatedAt: new Date().getTime(),
userSince: settings.value.userSince,
hasCustomBabel: false,
hasCustomWebpack: false,
hasStaticDirs: false,
Expand Down
2 changes: 2 additions & 0 deletions code/core/src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type EventType =
| 'index'
| 'upgrade'
| 'init'
| 'init-step'
| 'scaffolded-empty'
| 'browser'
| 'canceled'
Expand Down Expand Up @@ -40,6 +41,7 @@ export type StorybookMetadata = {
storybookVersion?: string;
storybookVersionSpecifier: string;
generatedAt?: number;
userSince?: number;
language: 'typescript' | 'javascript';
framework?: {
name: string;
Expand Down
10 changes: 9 additions & 1 deletion code/lib/cli-storybook/src/bin/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { globalSettings } from 'storybook/internal/cli';
import {
JsPackageManagerFactory,
removeAddon as remove,
Expand Down Expand Up @@ -35,7 +36,14 @@ const command = (name: string) =>
process.env.STORYBOOK_DISABLE_TELEMETRY && process.env.STORYBOOK_DISABLE_TELEMETRY !== 'false'
)
.option('--debug', 'Get more logs in debug mode', false)
.option('--enable-crash-reports', 'Enable sending crash reports to telemetry data');
.option('--enable-crash-reports', 'Enable sending crash reports to telemetry data')
.hook('preAction', async () => {
try {
await globalSettings();
} catch (e) {
consoleLogger.info('Error loading global settings', e);
}
});

command('init')
.description('Initialize Storybook into your project')
Expand Down
Loading
Loading