diff --git a/.gitignore b/.gitignore index 87d8fc3406..1231f16873 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ npm-debug.log yarn-error.log packages/*/dist +__testFs__ coverage cypress/screenshots diff --git a/.jest/config.cjs b/.jest/config.cjs index 631085316b..2e8f8191f2 100644 --- a/.jest/config.cjs +++ b/.jest/config.cjs @@ -21,6 +21,7 @@ module.exports = { testMatch: ['**/__tests__/**/*.{ts,tsx}', '**/?(*.)test.{ts,tsx}'], testPathIgnorePatterns: ['/node_modules/'], setupFilesAfterEnv: ['/.jest/setup.ts'], + modulePathIgnorePatterns: ['__testFs__'], moduleNameMapper: { // This seems faster than transpiling node_modules/lodash-es 'lodash-es': '/node_modules/lodash/lodash.js', @@ -28,11 +29,20 @@ module.exports = { // is a noop because wp isn't meant to be used in a browser environment. // Issue introduced here https://github.com/websockets/ws/pull/2118 ws: '/node_modules/ws/index.js', + // These files are mocked because they are only available after + // Cosmos packages are built, and tests should run with source code only. + 'react-cosmos-ui/dist/playground.bundle.js.map': + '/packages/react-cosmos/src/server/testMocks/playground.bundle.js.map', + 'react-cosmos-ui/dist/playground.bundle.js': + '/packages/react-cosmos/src/server/testMocks/playground.bundle.js', }, // https://kulshekhar.github.io/ts-jest/docs/getting-started/options/tsconfig transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: { noUnusedLocals: false } }], - '^.+\\.js$': ['ts-jest', { tsconfig: { allowJs: true } }], + '^.+\\.js$': [ + 'ts-jest', + { tsconfig: { allowJs: true, noUnusedLocals: false } }, + ], }, // https://jestjs.io/docs/configuration#transformignorepatterns-arraystring transformIgnorePatterns: [ @@ -45,6 +55,7 @@ module.exports = { '!**/*.fixture.{js,ts,tsx}', '!**/cosmos.decorator.{js,ts,tsx}', '!**/testHelpers/**', + '!**/testMocks/**', '!**/@types/**', // Ignore coverage from dark launched plugins '!packages/react-cosmos-ui/src/plugins/PluginList/**', diff --git a/README.md b/README.md index b25f802b49..974b766494 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > 🚀 **React Cosmos 6 is here! Check out the [migration guide](docs/MIGRATION_V6.md) to get started.** -[![npm version](https://img.shields.io/npm/v/react-cosmos.svg?style=flat)](https://www.npmjs.com/package/react-cosmos) [![CI Status](https://github.com/react-cosmos/react-cosmos/actions/workflows/test.yml/badge.svg)](https://github.com/react-cosmos/react-cosmos/actions/workflows/test.yml) [![Twitter](https://img.shields.io/badge/twitter-follow-%2300acee)](https://twitter.com/ReactCosmos) [![Discord](https://img.shields.io/discord/620737684859781150?color=%236D74EF)](https://discord.gg/3X95VgfnW5) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/react-cosmos/react-cosmos/blob/main/CONTRIBUTING.md#how-to-contribute) +[![npm version](https://img.shields.io/npm/v/react-cosmos.svg?style=flat)](https://www.npmjs.com/package/react-cosmos) [![CI Status](https://github.com/react-cosmos/react-cosmos/actions/workflows/test.yml/badge.svg)](https://github.com/react-cosmos/react-cosmos/actions/workflows/test.yml) ![Codecov](https://img.shields.io/codecov/c/github/react-cosmos/react-cosmos) [![Twitter](https://img.shields.io/badge/twitter-follow-%2300acee)](https://twitter.com/ReactCosmos) [![Discord](https://img.shields.io/discord/620737684859781150?color=%236D74EF)](https://discord.gg/3X95VgfnW5) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/react-cosmos/react-cosmos/blob/main/CONTRIBUTING.md#how-to-contribute) Sandbox for developing and testing UI components in isolation. diff --git a/cypress/cypress.config.ts b/cypress/cypress.config.ts index cfdf06e6b5..789ca64560 100644 --- a/cypress/cypress.config.ts +++ b/cypress/cypress.config.ts @@ -6,5 +6,10 @@ export default defineConfig({ modifyObstructiveCode: false, e2e: { specPattern: 'cypress/tests/**/*.{js,jsx,ts,tsx}', + // Disabled test isoation to try to fix flakiness of domDev test on Windows. + // Every now and then it would fail before running any test with the message: + // "This page was cleared by navigating to about:blank." + // Found it here: https://stackoverflow.com/a/74762655 + testIsolation: false, }, }); diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 14ab1ccc1c..80f7be7b0b 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -19,12 +19,13 @@ - [x] Extract react-cosmos-plugin-open-fixture plugin package. - [x] Move Boolean input plugin from example to plugin package. - [x] Add support for server plugins. +- [x] Test plugin APIs. - [ ] Document plugin APIs. - [ ] Add guide for creating UI + server plugin. ## Quality of life -- [ ] Change default port because [port 5000 is taken on macOS 12](https://github.com/react-cosmos/react-cosmos/issues/1355). +- [x] Auto port retry because [port 5000 is taken on macOS 12](https://github.com/react-cosmos/react-cosmos/issues/1355). - [x] Fix issues with Yarn 2 and PnP [#946](https://github.com/react-cosmos/react-cosmos/issues/946) [#1266](https://github.com/react-cosmos/react-cosmos/issues/1266) [#1337](https://github.com/react-cosmos/react-cosmos/pull/1337) [#1386](https://github.com/react-cosmos/react-cosmos/issues/1386). - [x] Fix security issues. - [x] Drop IE support. diff --git a/examples/vite/cosmos.config.json b/examples/vite/cosmos.config.json index 7be692371f..b8f1bbdcdb 100644 --- a/examples/vite/cosmos.config.json +++ b/examples/vite/cosmos.config.json @@ -6,6 +6,5 @@ "react-cosmos-plugin-boolean-input", "react-cosmos-plugin-open-fixture", "react-cosmos-plugin-vite" - ], - "rendererUrl": "http://localhost:5050" + ] } diff --git a/examples/webpack/cosmos.config.json b/examples/webpack/cosmos.config.json index 43c99bb357..7c4bb0b9c9 100644 --- a/examples/webpack/cosmos.config.json +++ b/examples/webpack/cosmos.config.json @@ -2,6 +2,9 @@ "$schema": "../../packages/react-cosmos/config.schema.json", "globalImports": ["src/global.css"], "staticPath": "static", + "webpack": { + "overridePath": "webpack.override.js" + }, "plugins": [ "react-cosmos-plugin-boolean-input", "react-cosmos-plugin-open-fixture", diff --git a/examples/webpack/webpack.override.js b/examples/webpack/webpack.override.js new file mode 100644 index 0000000000..17fd3fa97c --- /dev/null +++ b/examples/webpack/webpack.override.js @@ -0,0 +1,4 @@ +export default function (webpackConfig, env) { + // Customize webpack config for Cosmos... + return webpackConfig; +} diff --git a/package.json b/package.json index 2947f9fa09..77e5f901bc 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/express": "4.17.17", "@types/fuzzaldrin-plus": "^0.6.2", "@types/glob": "^8.0.1", + "@types/isomorphic-fetch": "^0.0.36", "@types/jest": "^29.4.0", "@types/lodash-es": "^4.17.6", "@types/micromatch": "^4.0.2", @@ -66,6 +67,7 @@ "glob": "^8.1.0", "html-webpack-plugin": "^5.5.0", "http-server": "^14.1.1", + "isomorphic-fetch": "^3.0.0", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", "jest-styled-components": "^7.1.1", diff --git a/packages/react-cosmos-core/src/server/cosmosPluginConfig.ts b/packages/react-cosmos-core/src/server/cosmosPluginConfig.ts index 05c0905822..a96591f5d8 100644 --- a/packages/react-cosmos-core/src/server/cosmosPluginConfig.ts +++ b/packages/react-cosmos-core/src/server/cosmosPluginConfig.ts @@ -3,16 +3,14 @@ export type RawCosmosPluginConfig = { name: string; ui?: string; - devServer?: string; - export?: string; + server?: string; }; export type CosmosPluginConfig = { name: string; rootDir: string; ui?: string; - devServer?: string; - export?: string; + server?: string; }; export type UiCosmosPluginConfig = CosmosPluginConfig & { diff --git a/packages/react-cosmos-plugin-vite/cosmos.plugin.json b/packages/react-cosmos-plugin-vite/cosmos.plugin.json index 590cb7f6b8..58e4915107 100644 --- a/packages/react-cosmos-plugin-vite/cosmos.plugin.json +++ b/packages/react-cosmos-plugin-vite/cosmos.plugin.json @@ -1,5 +1,4 @@ { "name": "Vite", - "devServer": "dist/viteDevServerPlugin.js", - "export": "dist/viteExportPlugin.js" + "server": "dist/viteServerPlugin.js" } diff --git a/packages/react-cosmos-plugin-vite/src/createViteCosmosConfig.ts b/packages/react-cosmos-plugin-vite/src/createViteCosmosConfig.ts new file mode 100644 index 0000000000..76b9f9869c --- /dev/null +++ b/packages/react-cosmos-plugin-vite/src/createViteCosmosConfig.ts @@ -0,0 +1,17 @@ +import { CosmosConfig } from 'react-cosmos/server.js'; + +type ViteCosmosConfig = { + port: number; +}; + +type ViteCosmosConfigInput = Partial; + +export function createViteCosmosConfig( + cosmosConfig: CosmosConfig +): ViteCosmosConfig { + const configInput: ViteCosmosConfigInput = cosmosConfig.vite || {}; + + return { + port: configInput.port || 5050, + }; +} diff --git a/packages/react-cosmos-plugin-vite/src/viteConfigPlugin.ts b/packages/react-cosmos-plugin-vite/src/viteConfigPlugin.ts new file mode 100644 index 0000000000..a0f0a0b497 --- /dev/null +++ b/packages/react-cosmos-plugin-vite/src/viteConfigPlugin.ts @@ -0,0 +1,30 @@ +import { + CosmosConfig, + CosmosConfigPluginArgs, + findNextAvailablePort, +} from 'react-cosmos/server.js'; +import { createViteCosmosConfig } from './createViteCosmosConfig.js'; + +export async function viteConfigPlugin({ + cosmosConfig, +}: CosmosConfigPluginArgs): Promise { + const { rendererUrl } = cosmosConfig; + if (rendererUrl) { + return cosmosConfig; + } + + const viteCosmosConfig = createViteCosmosConfig(cosmosConfig); + const port = await findNextAvailablePort( + viteCosmosConfig.port, + cosmosConfig.portRetries + ); + + return { + ...cosmosConfig, + rendererUrl: `http://localhost:${port}`, + vite: { + ...viteCosmosConfig, + port: port, + }, + }; +} diff --git a/packages/react-cosmos-plugin-vite/src/viteDevServerPlugin.ts b/packages/react-cosmos-plugin-vite/src/viteDevServerPlugin.ts index 2cb7520773..fcc4f8e2dd 100644 --- a/packages/react-cosmos-plugin-vite/src/viteDevServerPlugin.ts +++ b/packages/react-cosmos-plugin-vite/src/viteDevServerPlugin.ts @@ -8,7 +8,7 @@ import { userDepsResolvedModuleId, } from './reactCosmosViteRollupPlugin.js'; -export default async function viteDevServerPlugin({ +export async function viteDevServerPlugin({ platformType, cosmosConfig, }: DevServerPluginArgs) { diff --git a/packages/react-cosmos-plugin-vite/src/viteExportPlugin.ts b/packages/react-cosmos-plugin-vite/src/viteExportPlugin.ts index 62449557b4..4b9a904016 100644 --- a/packages/react-cosmos-plugin-vite/src/viteExportPlugin.ts +++ b/packages/react-cosmos-plugin-vite/src/viteExportPlugin.ts @@ -5,9 +5,7 @@ import { ExportPluginArgs, RENDERER_FILENAME } from 'react-cosmos/server.js'; import { build } from 'vite'; import { reactCosmosViteRollupPlugin } from './reactCosmosViteRollupPlugin.js'; -export default async function viteExportPlugin({ - cosmosConfig, -}: ExportPluginArgs) { +export async function viteExportPlugin({ cosmosConfig }: ExportPluginArgs) { const { exportPath, publicUrl } = cosmosConfig; await build({ diff --git a/packages/react-cosmos-plugin-vite/src/viteServerPlugin.ts b/packages/react-cosmos-plugin-vite/src/viteServerPlugin.ts new file mode 100644 index 0000000000..337a09049c --- /dev/null +++ b/packages/react-cosmos-plugin-vite/src/viteServerPlugin.ts @@ -0,0 +1,13 @@ +import { CosmosServerPlugin } from 'react-cosmos/server.js'; +import { viteConfigPlugin } from './viteConfigPlugin.js'; +import { viteDevServerPlugin } from './viteDevServerPlugin.js'; +import { viteExportPlugin } from './viteExportPlugin.js'; + +const viteServerPlugin: CosmosServerPlugin = { + name: 'vite', + config: viteConfigPlugin, + devServer: viteDevServerPlugin, + export: viteExportPlugin, +}; + +export default viteServerPlugin; diff --git a/packages/react-cosmos-plugin-webpack/cosmos.plugin.json b/packages/react-cosmos-plugin-webpack/cosmos.plugin.json index ffa32636b8..410566bb35 100644 --- a/packages/react-cosmos-plugin-webpack/cosmos.plugin.json +++ b/packages/react-cosmos-plugin-webpack/cosmos.plugin.json @@ -1,6 +1,5 @@ { "name": "Webpack", "ui": "dist/ui/build.js", - "devServer": "dist/server/webpackDevServerPlugin.js", - "export": "dist/server/webpackExportPlugin.js" + "server": "dist/server/webpackServerPlugin.js" } diff --git a/packages/react-cosmos-plugin-webpack/src/server/cosmosConfig/createWebpackCosmosConfig.ts b/packages/react-cosmos-plugin-webpack/src/server/cosmosConfig/createWebpackCosmosConfig.ts index 55bb5ca2ba..5e7b3c5a78 100644 --- a/packages/react-cosmos-plugin-webpack/src/server/cosmosConfig/createWebpackCosmosConfig.ts +++ b/packages/react-cosmos-plugin-webpack/src/server/cosmosConfig/createWebpackCosmosConfig.ts @@ -19,7 +19,7 @@ export function createWebpackCosmosConfig( cosmosConfig: CosmosConfig ): WebpackCosmosConfig { const { rootDir } = cosmosConfig; - const configInput = (cosmosConfig.webpack || {}) as WebpackCosmosConfigInput; + const configInput: WebpackCosmosConfigInput = cosmosConfig.webpack || {}; return { configPath: getWebpackConfigPath(configInput, rootDir), diff --git a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/customDevConfig.ts b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/customDevConfig.ts index 2363065b34..0c38142824 100644 --- a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/customDevConfig.ts +++ b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/customDevConfig.ts @@ -2,7 +2,7 @@ import { mockCliArgs, mockConsole, - mockFile, + mockCwdModuleDefault, unmockCliArgs, } from 'react-cosmos/jest.js'; import '../../testHelpers/mockEsmClientPath.js'; @@ -28,8 +28,8 @@ const mockWebpackOverride = jest.fn((webpackConfig: webpack.Configuration) => ({ beforeAll(() => { mockWebpackConfig.mockClear(); mockCliArgs({ env: { prod: true }, fooEnvVar: true }); - mockFile('mywebpack.config.js', mockWebpackConfig); - mockFile('mywebpack.override.js', mockWebpackOverride); + mockCwdModuleDefault('mywebpack.config.js', mockWebpackConfig); + mockCwdModuleDefault('mywebpack.override.js', mockWebpackOverride); }); afterAll(() => { diff --git a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/customExportConfig.ts b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/customExportConfig.ts index 74a397a04a..8e8745fb1a 100644 --- a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/customExportConfig.ts +++ b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/customExportConfig.ts @@ -1,5 +1,9 @@ // NOTE: Mock files need to imported before modules that use the mocked APIs -import { mockConsole, mockFile, unmockCliArgs } from 'react-cosmos/jest.js'; +import { + mockConsole, + mockCwdModuleDefault, + unmockCliArgs, +} from 'react-cosmos/jest.js'; import '../../testHelpers/mockEsmClientPath.js'; import '../../testHelpers/mockEsmLoaderPath.js'; import '../../testHelpers/mockEsmRequire.js'; @@ -15,7 +19,7 @@ import { getExportWebpackConfig } from '../getExportWebpackConfig.js'; import { HtmlWebpackPlugin } from '../htmlPlugin.js'; beforeAll(() => { - mockFile('mywebpack.config.js', { + mockCwdModuleDefault('mywebpack.config.js', { module: { rules: [MY_RULE] }, plugins: [MY_PLUGIN], }); @@ -31,7 +35,9 @@ const MY_PLUGIN = {}; async function getCustomExportWebpackConfig() { return mockConsole(async ({ expectLog }) => { expectLog('[Cosmos] Using webpack config found at mywebpack.config.js'); - expectLog('[Cosmos] Learn how to override webpack config for cosmos: https://github.com/react-cosmos/react-cosmos/tree/main/docs#webpack-config-override'); + expectLog( + '[Cosmos] Learn how to override webpack config for cosmos: https://github.com/react-cosmos/react-cosmos/tree/main/docs#webpack-config-override' + ); const cosmosConfig = createCosmosConfig(process.cwd(), { webpack: { configPath: 'mywebpack.config.js', diff --git a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/moduleScopePlugin.ts b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/moduleScopePlugin.ts index 1ad751db2a..e1ee5b7371 100644 --- a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/moduleScopePlugin.ts +++ b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/moduleScopePlugin.ts @@ -1,5 +1,5 @@ // NOTE: Mock files need to imported before modules that use the mocked APIs -import { mockConsole, mockFile } from 'react-cosmos/jest.js'; +import { mockConsole, mockCwdModuleDefault } from 'react-cosmos/jest.js'; import '../../testHelpers/mockEsmClientPath.js'; import '../../testHelpers/mockEsmLoaderPath.js'; import '../../testHelpers/mockEsmRequire.js'; @@ -12,6 +12,9 @@ import { getDevWebpackConfig } from '../getDevWebpackConfig.js'; async function getCustomDevWebpackConfig() { return mockConsole(async ({ expectLog }) => { expectLog('[Cosmos] Using webpack config found at mywebpack.config.js'); + expectLog( + '[Cosmos] Learn how to override webpack config for cosmos: https://github.com/react-cosmos/react-cosmos/tree/main/docs#webpack-config-override' + ); const cosmosConfig = createCosmosConfig(process.cwd(), { webpack: { configPath: 'mywebpack.config.js', @@ -26,7 +29,7 @@ class BarResolvePlugin {} class ModuleScopePlugin {} it('removes ModuleScopePlugin resolve plugin', async () => { - mockFile('mywebpack.config.js', () => ({ + mockCwdModuleDefault('mywebpack.config.js', () => ({ resolve: { plugins: [new ModuleScopePlugin()], }, @@ -37,7 +40,7 @@ it('removes ModuleScopePlugin resolve plugin', async () => { }); it('keeps other resolve plugins', async () => { - mockFile('mywebpack.config.js', () => ({ + mockCwdModuleDefault('mywebpack.config.js', () => ({ resolve: { plugins: [ new ModuleScopePlugin(), diff --git a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/reactAliases.ts b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/reactAliases.ts index 9f4c0357f3..58317a62b3 100644 --- a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/reactAliases.ts +++ b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/__tests__/reactAliases.ts @@ -1,5 +1,5 @@ // NOTE: Mock files need to imported before modules that use the mocked APIs -import { mockConsole, mockFile } from 'react-cosmos/jest.js'; +import { mockConsole, mockCwdModuleDefault } from 'react-cosmos/jest.js'; import '../../testHelpers/mockEsmClientPath.js'; import '../../testHelpers/mockEsmLoaderPath.js'; import '../../testHelpers/mockEsmRequire.js'; @@ -10,9 +10,15 @@ import { createCosmosConfig } from 'react-cosmos/server.js'; import webpack from 'webpack'; import { getDevWebpackConfig } from '../getDevWebpackConfig.js'; -async function getCustomDevWebpackConfig() { +async function getCustomDevWebpackConfig(expectAliasLog: boolean) { return mockConsole(async ({ expectLog }) => { expectLog('[Cosmos] Using webpack config found at mywebpack.config.js'); + expectLog( + '[Cosmos] Learn how to override webpack config for cosmos: https://github.com/react-cosmos/react-cosmos/tree/main/docs#webpack-config-override' + ); + if (expectAliasLog) { + expectLog('[Cosmos] React and React DOM aliases found in webpack config'); + } const cosmosConfig = createCosmosConfig(process.cwd(), { webpack: { configPath: 'mywebpack.config.js', @@ -23,7 +29,7 @@ async function getCustomDevWebpackConfig() { } it('preserves React aliases', async () => { - mockFile('mywebpack.config.js', () => ({ + mockCwdModuleDefault('mywebpack.config.js', () => ({ resolve: { alias: { react: 'preact/compat', @@ -32,7 +38,7 @@ it('preserves React aliases', async () => { }, })); - const { resolve } = await getCustomDevWebpackConfig(); + const { resolve } = await getCustomDevWebpackConfig(true); if (resolve && resolve.alias && !Array.isArray(resolve.alias)) { expect(resolve.alias.react).toEqual('preact/compat'); expect(resolve.alias['react-dom']).toEqual('preact/compat'); @@ -42,7 +48,7 @@ it('preserves React aliases', async () => { }); it('preserves React aliases with exact matches', async () => { - mockFile('mywebpack.config.js', () => ({ + mockCwdModuleDefault('mywebpack.config.js', () => ({ resolve: { alias: { react$: 'preact/compat', @@ -51,7 +57,7 @@ it('preserves React aliases with exact matches', async () => { }, })); - const { resolve } = await getCustomDevWebpackConfig(); + const { resolve } = await getCustomDevWebpackConfig(true); if (resolve && resolve.alias && !Array.isArray(resolve.alias)) { expect(resolve.alias.react$).toEqual('preact/compat'); expect(resolve.alias.react).toBeUndefined(); @@ -63,7 +69,7 @@ it('preserves React aliases with exact matches', async () => { }); it('preserves React aliases using array form', async () => { - mockFile('mywebpack.config.js', () => ({ + mockCwdModuleDefault('mywebpack.config.js', () => ({ resolve: { alias: [ { name: 'react', alias: 'preact/compat' }, @@ -72,7 +78,7 @@ it('preserves React aliases using array form', async () => { }, })); - const { resolve } = await getCustomDevWebpackConfig(); + const { resolve } = await getCustomDevWebpackConfig(true); if (resolve && Array.isArray(resolve.alias)) { expect(resolve.alias).toContainEqual({ name: 'react', @@ -88,7 +94,7 @@ it('preserves React aliases using array form', async () => { }); it('adds missing React aliases', async () => { - mockFile('mywebpack.config.js', () => ({ + mockCwdModuleDefault('mywebpack.config.js', () => ({ resolve: { alias: { xyz: 'abc', @@ -96,7 +102,7 @@ it('adds missing React aliases', async () => { }, })); - const { resolve } = await getCustomDevWebpackConfig(); + const { resolve } = await getCustomDevWebpackConfig(false); if (resolve && resolve.alias && !Array.isArray(resolve.alias)) { expect(resolve.alias.xyz).toBe('abc'); expect(resolve.alias.react).toMatch( @@ -111,13 +117,13 @@ it('adds missing React aliases', async () => { }); it('adds missing React aliases using array form', async () => { - mockFile('mywebpack.config.js', () => ({ + mockCwdModuleDefault('mywebpack.config.js', () => ({ resolve: { alias: [{ name: 'xyz', alias: 'abc' }], }, })); - const { resolve } = await getCustomDevWebpackConfig(); + const { resolve } = await getCustomDevWebpackConfig(false); if (resolve && Array.isArray(resolve.alias)) { expect(resolve.alias).toContainEqual({ name: 'xyz', diff --git a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/getUserWebpackConfig.ts b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/getUserWebpackConfig.ts index 5f3ce33b50..63b03fe7e9 100644 --- a/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/getUserWebpackConfig.ts +++ b/packages/react-cosmos-plugin-webpack/src/server/webpackConfig/getUserWebpackConfig.ts @@ -1,16 +1,16 @@ -import path from 'path'; +import path from 'node:path'; import { CosmosConfig, getCliArgs, - moduleExists, importModule, + moduleExists, } from 'react-cosmos/server.js'; import webpack from 'webpack'; import { createWebpackCosmosConfig } from '../cosmosConfig/createWebpackCosmosConfig.js'; import { getDefaultWebpackConfig } from './getDefaultWebpackConfig.js'; import { getWebpackNodeEnv } from './getWebpackNodeEnv.js'; -type WebpackConfigExport = +type WebpackConfig = | webpack.Configuration // Mirror webpack API for config functions // https://webpack.js.org/configuration/configuration-types/#exporting-a-function @@ -37,15 +37,17 @@ export async function getUserWebpackConfig( const { overridePath } = createWebpackCosmosConfig(cosmosConfig); if (!overridePath || !moduleExists(overridePath)) { - console.log(`[Cosmos] Learn how to override webpack config for cosmos: https://github.com/react-cosmos/react-cosmos/tree/main/docs#webpack-config-override`); + console.log( + `[Cosmos] Learn how to override webpack config for cosmos: https://github.com/react-cosmos/react-cosmos/tree/main/docs#webpack-config-override` + ); return baseWebpackConfig; } const relPath = path.relative(process.cwd(), overridePath); console.log(`[Cosmos] Overriding webpack config at ${relPath}`); - const webpackOverride = getDefaultExport( - await importModule(overridePath) - ); + + const module = await importModule<{ default: WebpackOverride }>(overridePath); + const webpackOverride = module.default; return webpackOverride(baseWebpackConfig, getWebpackNodeEnv()); } @@ -65,21 +67,11 @@ async function getBaseWebpackConfig( const relPath = path.relative(process.cwd(), configPath); console.log(`[Cosmos] Using webpack config found at ${relPath}`); - const userConfigExport = getDefaultExport( - await importModule(configPath) - ); - const cliArgs = getCliArgs(); - return typeof userConfigExport === 'function' - ? await userConfigExport(cliArgs.env || getWebpackNodeEnv(), cliArgs) - : userConfigExport; -} - -// Get "default" export from either an ES or CJS module -// More context: https://github.com/react-cosmos/react-cosmos/issues/895 -function getDefaultExport(module: T | { default: T }): T { - if (typeof module === 'object' && 'default' in module) { - return module.default; - } + const module = await importModule<{ default: WebpackConfig }>(configPath); + const webpackConfig = module.default; - return module; + const cliArgs = getCliArgs(); + return typeof webpackConfig === 'function' + ? await webpackConfig(cliArgs.env || getWebpackNodeEnv(), cliArgs) + : webpackConfig; } diff --git a/packages/react-cosmos-plugin-webpack/src/server/webpackDevServerPlugin.ts b/packages/react-cosmos-plugin-webpack/src/server/webpackDevServerPlugin.ts index a69b01099f..fc8ea7c2c4 100644 --- a/packages/react-cosmos-plugin-webpack/src/server/webpackDevServerPlugin.ts +++ b/packages/react-cosmos-plugin-webpack/src/server/webpackDevServerPlugin.ts @@ -1,8 +1,8 @@ -import webpackHotMiddleware from 'webpack-hot-middleware'; import path from 'path'; import { removeLeadingDot, ServerMessage } from 'react-cosmos-core'; import { DevServerPluginArgs, serveStaticDir } from 'react-cosmos/server.js'; import webpack from 'webpack'; +import webpackHotMiddleware from 'webpack-hot-middleware'; import { createWebpackCosmosConfig } from './cosmosConfig/createWebpackCosmosConfig.js'; import { getWebpack } from './getWebpack.js'; import { getDevWebpackConfig } from './webpackConfig/getDevWebpackConfig.js'; @@ -14,7 +14,7 @@ type WebpackConfig = webpack.Configuration & { }; }; -export default async function webpackDevServerPlugin({ +export async function webpackDevServerPlugin({ platformType, cosmosConfig, expressApp, diff --git a/packages/react-cosmos-plugin-webpack/src/server/webpackExportPlugin.ts b/packages/react-cosmos-plugin-webpack/src/server/webpackExportPlugin.ts index 86608f5121..32c351f737 100644 --- a/packages/react-cosmos-plugin-webpack/src/server/webpackExportPlugin.ts +++ b/packages/react-cosmos-plugin-webpack/src/server/webpackExportPlugin.ts @@ -3,9 +3,7 @@ import webpack, { StatsCompilation } from 'webpack'; import { getWebpack } from './getWebpack.js'; import { getExportWebpackConfig } from './webpackConfig/getExportWebpackConfig.js'; -export default async function webpackExportPlugin({ - cosmosConfig, -}: ExportPluginArgs) { +export async function webpackExportPlugin({ cosmosConfig }: ExportPluginArgs) { const userWebpack = getWebpack(cosmosConfig.rootDir); if (!userWebpack) { return; diff --git a/packages/react-cosmos-plugin-webpack/src/server/webpackServerPlugin.ts b/packages/react-cosmos-plugin-webpack/src/server/webpackServerPlugin.ts new file mode 100644 index 0000000000..1d6e59813d --- /dev/null +++ b/packages/react-cosmos-plugin-webpack/src/server/webpackServerPlugin.ts @@ -0,0 +1,11 @@ +import { CosmosServerPlugin } from 'react-cosmos/server.js'; +import { webpackDevServerPlugin } from './webpackDevServerPlugin.js'; +import { webpackExportPlugin } from './webpackExportPlugin.js'; + +const webpackServerPlugin: CosmosServerPlugin = { + name: 'webpack', + devServer: webpackDevServerPlugin, + export: webpackExportPlugin, +}; + +export default webpackServerPlugin; diff --git a/packages/react-cosmos-ui/src/playground.tsx b/packages/react-cosmos-ui/src/playground.tsx index 74185ebaa3..2f542086dc 100644 --- a/packages/react-cosmos-ui/src/playground.tsx +++ b/packages/react-cosmos-ui/src/playground.tsx @@ -51,7 +51,12 @@ export default async function mount({ async function loadPluginScript(scriptPath: string) { console.log(`[Cosmos] Loading plugin script at ${scriptPath}`); - // Handle both absolute (dev server) and relative paths (static export) + // Paths are absolute with the dev server, and relative with static + // exports. Why aren't they always relative? Because in dev mode + // the plugins could be loaded from folders outside the project rootDir, + // for example when using a monorepo. In that case relative paths would + // have to contain "../" segments, which are not allowed in URLs, and + // for this reason we pass full paths when using the dev server. const normalizedPath = scriptPath.startsWith('/') ? scriptPath : `/${scriptPath}`; diff --git a/packages/react-cosmos/config.schema.json b/packages/react-cosmos/config.schema.json index 997a3586b1..76eae3c9da 100644 --- a/packages/react-cosmos/config.schema.json +++ b/packages/react-cosmos/config.schema.json @@ -78,6 +78,10 @@ "description": "Dev server port. [default: 5000]", "type": "number" }, + "portRetries": { + "description": "Max number of port retries. [default: 10]", + "type": "number" + }, "https": { "description": "Server will be served over HTTPS", "type": "boolean" diff --git a/packages/react-cosmos/src/server.ts b/packages/react-cosmos/src/server.ts index fc3b7b949e..06b5f1eea3 100644 --- a/packages/react-cosmos/src/server.ts +++ b/packages/react-cosmos/src/server.ts @@ -6,6 +6,7 @@ export * from './server/cosmosPlugin/findCosmosPluginConfigs.js'; export * from './server/cosmosPlugin/pluginConfigs.js'; export * from './server/cosmosPlugin/types.js'; export * from './server/getFixtures/getFixtures.js'; +export * from './server/shared/findNextAvailablePort.js'; export { RENDERER_FILENAME } from './server/shared/playgroundHtml.js'; export * from './server/shared/playgroundUrl.js'; export * from './server/shared/staticServer.js'; diff --git a/packages/react-cosmos/src/server/corePlugins/httpProxy.ts b/packages/react-cosmos/src/server/corePlugins/httpProxy.ts new file mode 100644 index 0000000000..efa494320c --- /dev/null +++ b/packages/react-cosmos/src/server/corePlugins/httpProxy.ts @@ -0,0 +1,39 @@ +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { CosmosConfig } from '../cosmosConfig/types.js'; +import { CosmosServerPlugin } from '../cosmosPlugin/types.js'; + +type HttpProxyConfig = { + [context: string]: + | string + | { + target: string; + secure?: boolean; + pathRewrite?: { [rewrite: string]: string }; + logLevel?: 'error' | 'debug' | 'info' | 'warn' | 'silent'; + }; +}; + +export const httpProxyServerPlugin: CosmosServerPlugin = { + name: 'httpProxy', + + devServer({ platformType, cosmosConfig, expressApp }) { + if (platformType !== 'web') return; + + const httpProxyConfig = getHttpProxyCosmosConfig(cosmosConfig); + Object.keys(httpProxyConfig).forEach(context => { + const config = httpProxyConfig[context]; + if (typeof config === 'string') { + expressApp.use( + context, + createProxyMiddleware(context, { target: config }) + ); + } else if (typeof config === 'object') { + expressApp.use(context, createProxyMiddleware(context, config)); + } + }); + }, +}; + +function getHttpProxyCosmosConfig(cosmosConfig: CosmosConfig) { + return (cosmosConfig.httpProxy || {}) as HttpProxyConfig; +} diff --git a/packages/react-cosmos/src/server/corePlugins/index.ts b/packages/react-cosmos/src/server/corePlugins/index.ts new file mode 100644 index 0000000000..98116e5b9b --- /dev/null +++ b/packages/react-cosmos/src/server/corePlugins/index.ts @@ -0,0 +1,14 @@ +import { CosmosServerPlugin } from '../cosmosPlugin/types.js'; +import { httpProxyServerPlugin } from './httpProxy.js'; +import { openFileServerPlugin } from './openFile.js'; +import { pluginEndpointServerPlugin } from './pluginEndpoint.js'; +import { portRetryServerPlugin } from './portRetry.js'; +import { userDepsFileServerPlugin } from './userDepsFile.js'; + +export const coreServerPlugins: CosmosServerPlugin[] = [ + portRetryServerPlugin, + userDepsFileServerPlugin, + httpProxyServerPlugin, + openFileServerPlugin, + pluginEndpointServerPlugin, +]; diff --git a/packages/react-cosmos/src/server/corePlugins/openFile.ts b/packages/react-cosmos/src/server/corePlugins/openFile.ts new file mode 100644 index 0000000000..11b98b55e5 --- /dev/null +++ b/packages/react-cosmos/src/server/corePlugins/openFile.ts @@ -0,0 +1,66 @@ +import launchEditor from '@skidding/launch-editor'; +import express from 'express'; +import fs from 'fs'; +import open from 'open'; +import path from 'path'; +import { CosmosServerPlugin } from '../cosmosPlugin/types.js'; + +type ReqQuery = { filePath: void | string; line: number; column: number }; + +export const openFileServerPlugin: CosmosServerPlugin = { + name: 'openFile', + + devServer({ cosmosConfig, expressApp }) { + expressApp.get('/_open', (req: express.Request, res: express.Response) => { + const { filePath, line, column } = getReqQuery(req); + if (!filePath) { + res.status(400).send(`File path missing`); + return; + } + + const absFilePath = resolveFilePath(cosmosConfig.rootDir, filePath); + if (!fs.existsSync(absFilePath)) { + res.status(404).send(`File not found at path: ${absFilePath}`); + return; + } + + new Promise((resolve, reject) => { + const file = `${absFilePath}:${line}:${column}`; + launchEditor(file, (fileName, errorMsg) => reject(errorMsg)); + // If launchEditor doesn't report error within 500ms we assume it worked + setTimeout(resolve, 500); + }) + // Fall back to open in case launchEditor fails. launchEditor only works + // when the editor app is already open, but is favorable because it can + // open a code file on a specific line & column. + .catch(err => open(absFilePath)) + .catch(err => res.status(500).send('Failed to open file')) + .then(() => res.send()); + }); + }, +}; + +function getReqQuery(req: express.Request): ReqQuery { + const { filePath, line, column } = req.query; + if (typeof filePath !== 'string') throw new Error('filePath missing'); + return { + filePath, + line: typeof line === 'string' ? parseInt(line, 10) : 1, + column: typeof column === 'string' ? parseInt(column, 10) : 1, + }; +} + +function resolveFilePath(rootDir: string, filePath: string) { + // This heuristic is needed because the open file endpoint is used for + // multiple applications, which provide different file path types: + // 1. Edit fixture button: Sends path relative to Cosmos rootDir + // 2. react-error-overlay runtime error: Sends absolute path + // 3. react-error-overlay compile error: Sends path relative to CWD + if (path.isAbsolute(filePath)) { + return filePath; + } + + const cosmosRelPath = path.join(rootDir, filePath); + const cwdRelPath = path.join(process.cwd(), filePath); + return fs.existsSync(cosmosRelPath) ? cosmosRelPath : cwdRelPath; +} diff --git a/packages/react-cosmos/src/server/corePlugins/pluginEndpoint.ts b/packages/react-cosmos/src/server/corePlugins/pluginEndpoint.ts new file mode 100644 index 0000000000..91e4aac7d4 --- /dev/null +++ b/packages/react-cosmos/src/server/corePlugins/pluginEndpoint.ts @@ -0,0 +1,35 @@ +import express from 'express'; +import path from 'node:path'; +import { CosmosServerPlugin } from '../cosmosPlugin/types.js'; +import { resolveSilent } from '../utils/resolveSilent.js'; + +export const pluginEndpointServerPlugin: CosmosServerPlugin = { + name: 'pluginEndpoint', + + devServer({ cosmosConfig, expressApp }) { + expressApp.get( + '/_plugin/*.js', + (req: express.Request, res: express.Response) => { + const modulePath = req.params['0']; + + if (!modulePath) { + res.sendStatus(404); + return; + } + + // The module path is always absolute, but Windows paths don't start + // with a slash (e.g. C:\foo\bar.js) + const resolvedPath = resolveSilent( + path.isAbsolute(modulePath) ? modulePath : `/${modulePath}` + ); + + if (!resolvedPath) { + res.sendStatus(404); + return; + } + + res.sendFile(resolvedPath); + } + ); + }, +}; diff --git a/packages/react-cosmos/src/server/corePlugins/portRetry.ts b/packages/react-cosmos/src/server/corePlugins/portRetry.ts new file mode 100644 index 0000000000..b91f3f00cf --- /dev/null +++ b/packages/react-cosmos/src/server/corePlugins/portRetry.ts @@ -0,0 +1,14 @@ +import { CosmosServerPlugin } from '../cosmosPlugin/types.js'; +import { findNextAvailablePort } from '../shared/findNextAvailablePort.js'; + +export const portRetryServerPlugin: CosmosServerPlugin = { + name: 'portRetry', + + async config({ cosmosConfig }) { + const { port, portRetries } = cosmosConfig; + return { + ...cosmosConfig, + port: portRetries ? await findNextAvailablePort(port, portRetries) : port, + }; + }, +}; diff --git a/packages/react-cosmos/src/server/devServer/corePlugins/userDepsFile.tsx b/packages/react-cosmos/src/server/corePlugins/userDepsFile.ts similarity index 52% rename from packages/react-cosmos/src/server/devServer/corePlugins/userDepsFile.tsx rename to packages/react-cosmos/src/server/corePlugins/userDepsFile.ts index 98c15297f7..6cb789a73a 100644 --- a/packages/react-cosmos/src/server/devServer/corePlugins/userDepsFile.tsx +++ b/packages/react-cosmos/src/server/corePlugins/userDepsFile.ts @@ -1,26 +1,30 @@ import fs from 'fs/promises'; import path from 'path'; import { RendererConfig } from 'react-cosmos-core'; -import { CosmosConfig } from '../../cosmosConfig/types.js'; -import { DevServerPluginArgs, PlatformType } from '../../cosmosPlugin/types.js'; -import { startFixtureWatcher } from '../../userDeps/fixtureWatcher.js'; -import { getPlaygroundUrl } from '../../shared/playgroundUrl.js'; -import { generateUserDepsModule } from '../../userDeps/generateUserDepsModule.js'; -import { getCliArgs } from '../../utils/cli.js'; - -export async function userDepsFileDevServerPlugin(args: DevServerPluginArgs) { - if (!shouldGenerateUserDepsFile(args.platformType)) return; - - const { cosmosConfig } = args; - await generateUserDepsFile(cosmosConfig); - const watcher = await startFixtureWatcher(cosmosConfig, 'all', () => { - generateUserDepsFile(cosmosConfig); - }); +import { CosmosConfig } from '../cosmosConfig/types.js'; +import { CosmosServerPlugin, PlatformType } from '../cosmosPlugin/types.js'; +import { getPlaygroundUrl } from '../shared/playgroundUrl.js'; +import { startFixtureWatcher } from '../userDeps/fixtureWatcher.js'; +import { generateUserDepsModule } from '../userDeps/generateUserDepsModule.js'; +import { getCliArgs } from '../utils/cli.js'; - return () => { - watcher.close(); - }; -} +export const userDepsFileServerPlugin: CosmosServerPlugin = { + name: 'userDepsFile', + + async devServer(args) { + if (!shouldGenerateUserDepsFile(args.platformType)) return; + + const { cosmosConfig } = args; + await generateUserDepsFile(cosmosConfig); + const watcher = await startFixtureWatcher(cosmosConfig, 'all', () => { + generateUserDepsFile(cosmosConfig); + }); + + return () => { + watcher.close(); + }; + }, +}; function shouldGenerateUserDepsFile(platformType: PlatformType): boolean { return ( diff --git a/packages/react-cosmos/src/server/cosmosConfig/createCosmosConfig.ts b/packages/react-cosmos/src/server/cosmosConfig/createCosmosConfig.ts index bf277c70a2..a71b3a9057 100644 --- a/packages/react-cosmos/src/server/cosmosConfig/createCosmosConfig.ts +++ b/packages/react-cosmos/src/server/cosmosConfig/createCosmosConfig.ts @@ -25,6 +25,7 @@ export function createCosmosConfig( httpsOptions: getHttpsOptions(cosmosConfigInput, rootDir), ignore: getIgnore(cosmosConfigInput), port: getPort(cosmosConfigInput), + portRetries: getPortRetries(cosmosConfigInput), plugins: getPlugins(cosmosConfigInput, rootDir), publicUrl: getPublicUrl(cosmosConfigInput), staticPath: getStaticPath(cosmosConfigInput, rootDir), @@ -112,6 +113,10 @@ function getPort(cosmosConfigInput: CosmosConfigInput) { return port; } +function getPortRetries({ portRetries = 10 }: CosmosConfigInput) { + return portRetries; +} + function getGlobalImports( cosmosConfigInput: CosmosConfigInput, rootDir: string diff --git a/packages/react-cosmos/src/server/cosmosConfig/shared.ts b/packages/react-cosmos/src/server/cosmosConfig/shared.ts index 2172ca8fa0..c15d48f1d3 100644 --- a/packages/react-cosmos/src/server/cosmosConfig/shared.ts +++ b/packages/react-cosmos/src/server/cosmosConfig/shared.ts @@ -1,4 +1,4 @@ -import { importModule } from '../utils/fs.js'; +import { importJson } from '../utils/fs.js'; import { CosmosConfigInput } from './types.js'; export function getCurrentDir() { @@ -6,5 +6,5 @@ export function getCurrentDir() { } export async function importConfigFile(cosmosConfigPath: string) { - return importModule(cosmosConfigPath); + return importJson(cosmosConfigPath); } diff --git a/packages/react-cosmos/src/server/cosmosConfig/types.ts b/packages/react-cosmos/src/server/cosmosConfig/types.ts index a6decf95a2..2025afda79 100644 --- a/packages/react-cosmos/src/server/cosmosConfig/types.ts +++ b/packages/react-cosmos/src/server/cosmosConfig/types.ts @@ -25,6 +25,7 @@ export type CosmosConfig = { httpsOptions: null | HttpsOptions; ignore: string[]; port: number; + portRetries: number; plugins: string[]; publicUrl: string; rendererUrl: string | null; diff --git a/packages/react-cosmos/src/server/cosmosPlugin/findCosmosPluginConfigs.test.ts b/packages/react-cosmos/src/server/cosmosPlugin/findCosmosPluginConfigs.test.ts index 7c3208f48c..3c60f41146 100644 --- a/packages/react-cosmos/src/server/cosmosPlugin/findCosmosPluginConfigs.test.ts +++ b/packages/react-cosmos/src/server/cosmosPlugin/findCosmosPluginConfigs.test.ts @@ -33,32 +33,21 @@ it('loads mono repo plugins', async () => { expect(configs).toContainEqual({ name: 'Vite', rootDir: 'react-cosmos-plugin-vite', - devServer: path.join( + server: path.join( 'react-cosmos-plugin-vite', 'dist', - 'viteDevServerPlugin.js' - ), - export: path.join( - 'react-cosmos-plugin-vite', - 'dist', - 'viteExportPlugin.js' + 'viteServerPlugin.js' ), }); expect(configs).toContainEqual({ name: 'Webpack', rootDir: 'react-cosmos-plugin-webpack', ui: path.join('react-cosmos-plugin-webpack', 'dist', 'ui', 'build.js'), - devServer: path.join( - 'react-cosmos-plugin-webpack', - 'dist', - 'server', - 'webpackDevServerPlugin.js' - ), - export: path.join( + server: path.join( 'react-cosmos-plugin-webpack', 'dist', 'server', - 'webpackExportPlugin.js' + 'webpackServerPlugin.js' ), }); }); diff --git a/packages/react-cosmos/src/server/cosmosPlugin/readCosmosPluginConfig.ts b/packages/react-cosmos/src/server/cosmosPlugin/readCosmosPluginConfig.ts index b40c2e4cfd..33e5f963d9 100644 --- a/packages/react-cosmos/src/server/cosmosPlugin/readCosmosPluginConfig.ts +++ b/packages/react-cosmos/src/server/cosmosPlugin/readCosmosPluginConfig.ts @@ -1,6 +1,6 @@ import path from 'path'; import { CosmosPluginConfig, RawCosmosPluginConfig } from 'react-cosmos-core'; -import { importModule } from '../utils/fs.js'; +import { importJson } from '../utils/fs.js'; import { resolveSilent } from '../utils/resolveSilent.js'; type ReadCosmosPluginConfigArgs = { @@ -13,7 +13,7 @@ export async function readCosmosPluginConfig({ configPath, relativePaths, }: ReadCosmosPluginConfigArgs) { - const rawConfig = await importModule(configPath); + const rawConfig = await importJson(configPath); const pluginRootDir = path.dirname(configPath); const config: CosmosPluginConfig = { @@ -33,22 +33,12 @@ export async function readCosmosPluginConfig({ ); } - if (rawConfig.devServer) { - config.devServer = resolvePluginPath( + if (rawConfig.server) { + config.server = resolvePluginPath( config.name, rootDir, pluginRootDir, - rawConfig.devServer, - relativePaths - ); - } - - if (rawConfig.export) { - config.export = resolvePluginPath( - config.name, - rootDir, - pluginRootDir, - rawConfig.export, + rawConfig.server, relativePaths ); } diff --git a/packages/react-cosmos/src/server/cosmosPlugin/types.ts b/packages/react-cosmos/src/server/cosmosPlugin/types.ts index 10620a4f99..c719e46b29 100644 --- a/packages/react-cosmos/src/server/cosmosPlugin/types.ts +++ b/packages/react-cosmos/src/server/cosmosPlugin/types.ts @@ -5,6 +5,15 @@ import { CosmosConfig } from '../cosmosConfig/types.js'; export type PlatformType = 'web' | 'native'; +export type CosmosConfigPluginArgs = { + cosmosConfig: CosmosConfig; + platformType: PlatformType; +}; + +export type CosmosConfigPlugin = ( + args: CosmosConfigPluginArgs +) => Promise | CosmosConfig; + export type DevServerPluginArgs = { cosmosConfig: CosmosConfig; platformType: PlatformType; @@ -31,3 +40,10 @@ export type ExportPluginArgs = { }; export type ExportPlugin = (args: ExportPluginArgs) => unknown; + +export type CosmosServerPlugin = { + name: string; + config?: CosmosConfigPlugin; + devServer?: DevServerPlugin; + export?: ExportPlugin; +}; diff --git a/packages/react-cosmos/src/server/devServer/__tests__/devServerPlugin.ts b/packages/react-cosmos/src/server/devServer/__tests__/devServerPlugin.ts new file mode 100644 index 0000000000..754b6ba367 --- /dev/null +++ b/packages/react-cosmos/src/server/devServer/__tests__/devServerPlugin.ts @@ -0,0 +1,160 @@ +// Import mocks first +import { jestWorkerId } from '../../testHelpers/jestWorkerId.js'; +import { mockConsole } from '../../testHelpers/mockConsole.js'; +import { mockCosmosPlugins } from '../../testHelpers/mockCosmosPlugins.js'; +import '../../testHelpers/mockEsmRequire.js'; +import '../../testHelpers/mockEsmResolve.js'; +import '../../testHelpers/mockEsmStaticPath.js'; +import { + mockCosmosConfig, + mockFile, + resetFsMock, +} from '../../testHelpers/mockFs.js'; +import { mockCliArgs, unmockCliArgs } from '../../testHelpers/mockYargs.js'; + +import retry from '@skidding/async-retry'; +import 'isomorphic-fetch'; +import * as http from 'node:http'; +import path from 'node:path'; +import { ServerMessage, SocketMessage } from 'react-cosmos-core'; +import { DevServerPluginArgs } from '../../cosmosPlugin/types.js'; +import { startDevServer } from '../startDevServer.js'; + +const testCosmosPlugin = { + name: 'Test Cosmos plugin', + rootDir: path.join(__dirname, 'mock-cosmos-plugin'), + server: path.join(__dirname, 'mock-cosmos-plugin/server.js'), +}; +mockCosmosPlugins([testCosmosPlugin]); + +const devServerCleanup = jest.fn(() => Promise.resolve()); +const testServerPlugin = { + name: 'testServerPlugin', + + config: jest.fn(async ({ cosmosConfig }) => { + return { + ...cosmosConfig, + ignore: ['**/ignored.fixture.js'], + }; + }), + + devServer: jest.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + return () => devServerCleanup(); + }), +}; + +const port = 5000 + jestWorkerId(); + +let _stopServer: (() => Promise) | undefined; + +async function stopServer() { + if (_stopServer) { + await _stopServer(); + _stopServer = undefined; + } +} + +beforeEach(() => { + mockCliArgs({}); + mockCosmosConfig('cosmos.config.json', { port }); + mockFile(testCosmosPlugin.server, { default: testServerPlugin }); + + devServerCleanup.mockClear(); + testServerPlugin.config.mockClear(); + testServerPlugin.devServer.mockClear(); +}); + +afterEach(async () => { + await stopServer(); + unmockCliArgs(); + resetFsMock(); +}); + +it('calls config hook', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + expect(testServerPlugin.config).toBeCalledWith({ + cosmosConfig: expect.objectContaining({ port }), + platformType: 'web', + }); + }); +}); + +it('calls dev server hook (with updated config)', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + expect(testServerPlugin.devServer).toBeCalledWith({ + cosmosConfig: expect.objectContaining({ + port, + ignore: ['**/ignored.fixture.js'], + }), + platformType: 'web', + expressApp: expect.any(Function), + httpServer: expect.any(http.Server), + sendMessage: expect.any(Function), + }); + + await stopServer(); + + expect(devServerCleanup).toBeCalled(); + }); +}); + +it('calls dev server hook with send message API', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + const client = new WebSocket(`ws://localhost:${port}`); + + const message: SocketMessage = { + channel: 'server', + message: { + type: 'buildStart', + }, + }; + + const onMessage = jest.fn(); + client.addEventListener('open', () => { + client.addEventListener('message', msg => onMessage(msg.data)); + + const [args] = testServerPlugin.devServer.mock + .calls[0] as DevServerPluginArgs[]; + args.sendMessage(message.message); + }); + + await retry(() => + expect(onMessage).toBeCalledWith(JSON.stringify(message)) + ); + }); +}); + +it('embeds plugin in playground HTML', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + const res = await fetch(`http://localhost:${port}`); + expect(res.status).toBe(200); + + const html = await res.text(); + expect(html).toContain(JSON.stringify([testCosmosPlugin])); + }); +}); diff --git a/packages/react-cosmos/src/server/devServer/__tests__/devServerUiPlugin.ts b/packages/react-cosmos/src/server/devServer/__tests__/devServerUiPlugin.ts new file mode 100644 index 0000000000..d319a2d821 --- /dev/null +++ b/packages/react-cosmos/src/server/devServer/__tests__/devServerUiPlugin.ts @@ -0,0 +1,89 @@ +// Import mocks first +import { jestWorkerId } from '../../testHelpers/jestWorkerId.js'; +import { mockConsole } from '../../testHelpers/mockConsole.js'; +import { mockCosmosPlugins } from '../../testHelpers/mockCosmosPlugins.js'; +import '../../testHelpers/mockEsmRequire.js'; +import '../../testHelpers/mockEsmResolve.js'; +import '../../testHelpers/mockEsmStaticPath.js'; +import { mockCosmosConfig, resetFsMock } from '../../testHelpers/mockFs.js'; +import { mockCliArgs, unmockCliArgs } from '../../testHelpers/mockYargs.js'; + +import 'isomorphic-fetch'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { startDevServer } from '../startDevServer.js'; + +const port = 5000 + jestWorkerId(); + +const testFsPath = path.join(__dirname, '../__testFs__'); +const pluginPath = path.join(testFsPath, `plugin-${jestWorkerId()}`); + +const testCosmosPlugin = { + name: 'Test Cosmos plugin', + rootDir: pluginPath, + ui: path.join(pluginPath, 'ui.js'), +}; +mockCosmosPlugins([testCosmosPlugin]); + +let _stopServer: (() => Promise) | undefined; + +async function stopServer() { + if (_stopServer) { + await _stopServer(); + _stopServer = undefined; + } +} + +beforeEach(async () => { + mockCliArgs({}); + mockCosmosConfig('cosmos.config.json', { + rootDir: testFsPath, + port, + }); + + await fs.mkdir(testCosmosPlugin.rootDir, { recursive: true }); + await fs.writeFile(testCosmosPlugin.ui, 'export {}', 'utf8'); +}); + +afterEach(async () => { + await stopServer(); + unmockCliArgs(); + resetFsMock(); + await fs.rm(pluginPath, { recursive: true, force: true }); +}); + +it('embeds plugin in playground HTML', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + const res = await fetch(`http://localhost:${port}`); + expect(res.status).toBe(200); + + const html = await res.text(); + expect(html).toContain(JSON.stringify([testCosmosPlugin])); + }); +}); + +it('serves plugin JS files', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + // Windows paths don't start with a slash (e.g. C:\foo\bar.js) + const uiPath = testCosmosPlugin.ui.startsWith('/') + ? testCosmosPlugin.ui + : `/${testCosmosPlugin.ui}`; + const res = await fetch(`http://localhost:${port}/_plugin${uiPath}`); + expect(res.status).toBe(200); + + const uiJs = await res.text(); + expect(uiJs).toBe('export {}'); + }); +}); diff --git a/packages/react-cosmos/src/server/devServer/__tests__/startDevServer.ts b/packages/react-cosmos/src/server/devServer/__tests__/startDevServer.ts new file mode 100644 index 0000000000..2329c50eb7 --- /dev/null +++ b/packages/react-cosmos/src/server/devServer/__tests__/startDevServer.ts @@ -0,0 +1,174 @@ +// Import mocks first +import { jestWorkerId } from '../../testHelpers/jestWorkerId.js'; +import { mockConsole } from '../../testHelpers/mockConsole.js'; +import { mockCosmosPlugins } from '../../testHelpers/mockCosmosPlugins.js'; +import '../../testHelpers/mockEsmRequire.js'; +import '../../testHelpers/mockEsmResolve.js'; +import '../../testHelpers/mockEsmStaticPath.js'; +import { mockCosmosConfig, resetFsMock } from '../../testHelpers/mockFs.js'; +import { mockCliArgs, unmockCliArgs } from '../../testHelpers/mockYargs.js'; + +import retry from '@skidding/async-retry'; +import 'isomorphic-fetch'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { ServerMessage, SocketMessage } from 'react-cosmos-core'; +import { startDevServer } from '../startDevServer.js'; + +mockCosmosPlugins([]); + +const port = 5000 + jestWorkerId(); + +let _stopServer: (() => Promise) | undefined; + +async function stopServer() { + if (_stopServer) { + await _stopServer(); + _stopServer = undefined; + } +} + +beforeEach(() => { + mockCliArgs({}); + mockCosmosConfig('cosmos.config.json', { port }); +}); + +afterEach(async () => { + await stopServer(); + unmockCliArgs(); + resetFsMock(); +}); + +it('serves playground HTML', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + const res = await fetch(`http://localhost:${port}`); + expect(res.status).toBe(200); + + const html = await res.text(); + expect(html).toContain('React Cosmos'); + expect(html).toContain(''); + + expect(html).toContain( + JSON.stringify({ + playgroundConfig: { + core: { + projectId: 'new-project', + fixturesDir: '__fixtures__', + fixtureFileSuffix: 'fixture', + devServerOn: true, + webRendererUrl: '/_renderer.html', + }, + }, + pluginConfigs: [], + }) + ); + }); +}); + +it('serves playground JS', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + const res1 = await fetch(`http://localhost:${port}/playground.bundle.js`); + expect(res1.status).toBe(200); + expect((await res1.text()).trim()).toBe('__mock_bundle__'); + + const res2 = await fetch( + `http://localhost:${port}/playground.bundle.js.map` + ); + expect(res2.status).toBe(200); + expect((await res2.text()).trim()).toBe('__mock_map__'); + }); +}); + +it('serves favicon', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + const res = await fetch(`http://localhost:${port}/_cosmos.ico`); + expect(res.status).toBe(200); + + expect(await res.text()).toEqual( + await fs.readFile( + path.join(__dirname, '../../static/favicon.ico'), + 'utf8' + ) + ); + }); +}); + +it('creates message handler', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + const client1 = new WebSocket(`ws://localhost:${port}`); + const client2 = new WebSocket(`ws://localhost:${port}`); + + const message: SocketMessage = { + channel: 'server', + message: { + type: 'buildStart', + }, + }; + + const onMessage = jest.fn(); + client1.addEventListener('open', () => { + client1.addEventListener('message', msg => onMessage(msg.data)); + client2.addEventListener('open', () => { + client2.send(JSON.stringify(message)); + }); + }); + + await retry(() => + expect(onMessage).toBeCalledWith(JSON.stringify(message)) + ); + }); +}); + +it('stops server', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + await stopServer(); + + await expect(fetch(`http://localhost:${port}`)).rejects.toThrow( + 'ECONNREFUSED' + ); + }); +}); + +it('closes message handler clients', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Using cosmos config found at cosmos.config.json'); + expectLog(`[Cosmos] See you at http://localhost:${port}`); + + _stopServer = await startDevServer('web'); + + const onOpen = jest.fn(); + const onClose = jest.fn(); + + const client1 = new WebSocket(`ws://localhost:${port}`); + client1.addEventListener('open', onOpen); + client1.addEventListener('close', onClose); + + await retry(() => expect(onOpen).toBeCalled()); + await stopServer(); + await retry(() => expect(onClose).toBeCalled()); + }); +}); diff --git a/packages/react-cosmos/src/server/devServer/corePlugins/httpProxy.ts b/packages/react-cosmos/src/server/devServer/corePlugins/httpProxy.ts deleted file mode 100644 index 8a5066e99c..0000000000 --- a/packages/react-cosmos/src/server/devServer/corePlugins/httpProxy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createProxyMiddleware } from 'http-proxy-middleware'; -import { CosmosConfig } from '../../cosmosConfig/types.js'; -import { DevServerPluginArgs } from '../../cosmosPlugin/types.js'; - -type HttpProxyConfig = { - [context: string]: - | string - | { - target: string; - secure?: boolean; - pathRewrite?: { [rewrite: string]: string }; - logLevel?: 'error' | 'debug' | 'info' | 'warn' | 'silent'; - }; -}; - -export function httpProxyDevServerPlugin({ - platformType, - cosmosConfig, - expressApp, -}: DevServerPluginArgs) { - if (platformType !== 'web') return; - - const httpProxyConfig = getHttpProxyCosmosConfig(cosmosConfig); - Object.keys(httpProxyConfig).forEach(context => { - const config = httpProxyConfig[context]; - if (typeof config === 'string') { - expressApp.use( - context, - createProxyMiddleware(context, { target: config }) - ); - } else if (typeof config === 'object') { - expressApp.use(context, createProxyMiddleware(context, config)); - } - }); -} - -function getHttpProxyCosmosConfig(cosmosConfig: CosmosConfig) { - return (cosmosConfig.httpProxy || {}) as HttpProxyConfig; -} diff --git a/packages/react-cosmos/src/server/devServer/corePlugins/openFile.ts b/packages/react-cosmos/src/server/devServer/corePlugins/openFile.ts deleted file mode 100644 index 7a1b2e09ac..0000000000 --- a/packages/react-cosmos/src/server/devServer/corePlugins/openFile.ts +++ /dev/null @@ -1,65 +0,0 @@ -import launchEditor from '@skidding/launch-editor'; -import express from 'express'; -import fs from 'fs'; -import open from 'open'; -import path from 'path'; -import { DevServerPluginArgs } from '../../cosmosPlugin/types.js'; - -type ReqQuery = { filePath: void | string; line: number; column: number }; - -export default function openFileDevServerPlugin({ - cosmosConfig, - expressApp, -}: DevServerPluginArgs) { - expressApp.get('/_open', (req: express.Request, res: express.Response) => { - const { filePath, line, column } = getReqQuery(req); - if (!filePath) { - res.status(400).send(`File path missing`); - return; - } - - const absFilePath = resolveFilePath(cosmosConfig.rootDir, filePath); - if (!fs.existsSync(absFilePath)) { - res.status(404).send(`File not found at path: ${absFilePath}`); - return; - } - - new Promise((resolve, reject) => { - const file = `${absFilePath}:${line}:${column}`; - launchEditor(file, (fileName, errorMsg) => reject(errorMsg)); - // If launchEditor doesn't report error within 500ms we assume it worked - setTimeout(resolve, 500); - }) - // Fall back to open in case launchEditor fails. launchEditor only works - // when the editor app is already open, but is favorable because it can - // open a code file on a specific line & column. - .catch(err => open(absFilePath)) - .catch(err => res.status(500).send('Failed to open file')) - .then(() => res.send()); - }); -} - -function getReqQuery(req: express.Request): ReqQuery { - const { filePath, line, column } = req.query; - if (typeof filePath !== 'string') throw new Error('filePath missing'); - return { - filePath, - line: typeof line === 'string' ? parseInt(line, 10) : 1, - column: typeof column === 'string' ? parseInt(column, 10) : 1, - }; -} - -function resolveFilePath(rootDir: string, filePath: string) { - // This heuristic is needed because the open file endpoint is used for - // multiple applications, which provide different file path types: - // 1. Edit fixture button: Sends path relative to Cosmos rootDir - // 2. react-error-overlay runtime error: Sends absolute path - // 3. react-error-overlay compile error: Sends path relative to CWD - if (path.isAbsolute(filePath)) { - return filePath; - } - - const cosmosRelPath = path.join(rootDir, filePath); - const cwdRelPath = path.join(process.cwd(), filePath); - return fs.existsSync(cosmosRelPath) ? cosmosRelPath : cwdRelPath; -} diff --git a/packages/react-cosmos/src/server/devServer/corePlugins/pluginEndpoint.ts b/packages/react-cosmos/src/server/devServer/corePlugins/pluginEndpoint.ts deleted file mode 100644 index 66cf759473..0000000000 --- a/packages/react-cosmos/src/server/devServer/corePlugins/pluginEndpoint.ts +++ /dev/null @@ -1,31 +0,0 @@ -import express from 'express'; -import { DevServerPluginArgs } from '../../cosmosPlugin/types.js'; -import { resolveFromSilent, resolveSilent } from '../../utils/resolveSilent.js'; - -export default function pluginEndpointDevServerPlugin({ - cosmosConfig, - expressApp, -}: DevServerPluginArgs) { - expressApp.get( - '/_plugin/*.js', - (req: express.Request, res: express.Response) => { - const moduleId = req.params['0']; - - if (!moduleId) { - res.sendStatus(404); - return; - } - - const resolvedPath = - resolveSilent(`/${moduleId}`) || - resolveFromSilent(cosmosConfig.rootDir, moduleId); - - if (!resolvedPath) { - res.sendStatus(404); - return; - } - - res.sendFile(resolvedPath); - } - ); -} diff --git a/packages/react-cosmos/src/server/devServer/expressApp.ts b/packages/react-cosmos/src/server/devServer/expressApp.ts index e7bba147e8..64c250b1bf 100644 --- a/packages/react-cosmos/src/server/devServer/expressApp.ts +++ b/packages/react-cosmos/src/server/devServer/expressApp.ts @@ -18,6 +18,7 @@ export async function createExpressApp( cosmosConfig, pluginConfigs ); + app.get('/', (req: express.Request, res: express.Response) => { res.send(playgroundHtml); }); diff --git a/packages/react-cosmos/src/server/devServer/messageHandler.ts b/packages/react-cosmos/src/server/devServer/messageHandler.ts index 16801affa6..85dc6dac6b 100644 --- a/packages/react-cosmos/src/server/devServer/messageHandler.ts +++ b/packages/react-cosmos/src/server/devServer/messageHandler.ts @@ -26,10 +26,10 @@ export function createMessageHandler(httpServer: http.Server) { }); } - function cleanUp() { + function close() { wss.clients.forEach(client => client.close()); wss.close(); } - return { sendMessage, cleanUp }; + return { sendMessage, close }; } diff --git a/packages/react-cosmos/src/server/devServer/startDevServer.ts b/packages/react-cosmos/src/server/devServer/startDevServer.ts index beca26f70a..46aaed42ca 100644 --- a/packages/react-cosmos/src/server/devServer/startDevServer.ts +++ b/packages/react-cosmos/src/server/devServer/startDevServer.ts @@ -1,35 +1,23 @@ import path from 'path'; -import { CosmosPluginConfig } from 'react-cosmos-core'; +import { coreServerPlugins } from '../corePlugins/index.js'; import { detectCosmosConfig, detectCosmosConfigPath, } from '../cosmosConfig/detectCosmosConfig.js'; import { getPluginConfigs } from '../cosmosPlugin/pluginConfigs.js'; import { - DevServerPlugin, DevServerPluginCleanupCallback, PlatformType, } from '../cosmosPlugin/types.js'; +import { importServerPlugins } from '../shared/importServerPlugins.js'; import { logPluginInfo } from '../shared/logPluginInfo.js'; -import { requirePluginModule } from '../shared/requirePluginModule.js'; import { serveStaticDir } from '../shared/staticServer.js'; -import { httpProxyDevServerPlugin } from './corePlugins/httpProxy.js'; -import openFileDevServerPlugin from './corePlugins/openFile.js'; -import pluginEndpointDevServerPlugin from './corePlugins/pluginEndpoint.js'; -import { userDepsFileDevServerPlugin } from './corePlugins/userDepsFile.js'; import { createExpressApp } from './expressApp.js'; import { createHttpServer } from './httpServer.js'; import { createMessageHandler } from './messageHandler.js'; -const builtInPlugins: DevServerPlugin[] = [ - userDepsFileDevServerPlugin, - httpProxyDevServerPlugin, - openFileDevServerPlugin, - pluginEndpointDevServerPlugin, -]; - export async function startDevServer(platformType: PlatformType) { - const cosmosConfig = await detectCosmosConfig(); + let cosmosConfig = await detectCosmosConfig(); logCosmosConfigInfo(); const pluginConfigs = await getPluginConfigs({ @@ -41,6 +29,23 @@ export async function startDevServer(platformType: PlatformType) { }); logPluginInfo(pluginConfigs); + const userPlugins = await importServerPlugins( + pluginConfigs, + cosmosConfig.rootDir + ); + const plugins = [...coreServerPlugins, ...userPlugins]; + + for (const plugin of plugins) { + if (plugin.config) { + try { + cosmosConfig = await plugin.config({ cosmosConfig, platformType }); + } catch (err) { + console.log(`[Cosmos][plugin:${plugin.name}] Config hook failed`); + throw err; + } + } + } + const app = await createExpressApp(platformType, cosmosConfig, pluginConfigs); const httpServer = await createHttpServer(cosmosConfig, app); const msgHandler = createMessageHandler(httpServer.server); @@ -53,18 +58,15 @@ export async function startDevServer(platformType: PlatformType) { async function cleanUp() { await Promise.all(pluginCleanupCallbacks.map(cleanup => cleanup())); + msgHandler.close(); await httpServer.stop(); - msgHandler.cleanUp(); } - const userPlugins = await getDevServerPlugins( - pluginConfigs, - cosmosConfig.rootDir - ); - - for (const plugin of [...builtInPlugins, ...userPlugins]) { + for (const plugin of plugins) { try { - const pluginReturn = await plugin({ + if (!plugin.devServer) continue; + + const pluginReturn = await plugin.devServer({ cosmosConfig, platformType, httpServer: httpServer.server, @@ -80,7 +82,7 @@ export async function startDevServer(platformType: PlatformType) { // Log when a plugin fails to clean up, but continue to attempt // to clean up the remaining plugins console.log( - `[Cosmos][${plugin.name}] Dev server plugin cleanup failed` + `[Cosmos][plugin:${plugin.name}] Dev server cleanup failed` ); console.log(err); } @@ -89,7 +91,7 @@ export async function startDevServer(platformType: PlatformType) { } catch (err) { // Abort starting server if a plugin init fails and attempt cleanup of all // plugins that have already initialized - console.log(`[Cosmos][${plugin.name}] Dev server plugin init failed`); + console.log(`[Cosmos][plugin:${plugin.name}] Dev server init failed`); cleanUp(); throw err; } @@ -112,16 +114,3 @@ function logCosmosConfigInfo() { const relConfigPath = path.relative(process.cwd(), cosmosConfigPath); console.log(`[Cosmos] Using cosmos config found at ${relConfigPath}`); } - -async function getDevServerPlugins( - pluginConfigs: CosmosPluginConfig[], - rootDir: string -) { - return Promise.all( - pluginConfigs - .filter(pluginConfig => pluginConfig.devServer) - .map(pluginConfig => - requirePluginModule(rootDir, pluginConfig, 'devServer') - ) - ); -} diff --git a/packages/react-cosmos/src/server/export/__tests__/exportServerPlugin.ts b/packages/react-cosmos/src/server/export/__tests__/exportServerPlugin.ts new file mode 100644 index 0000000000..b9fbf833e1 --- /dev/null +++ b/packages/react-cosmos/src/server/export/__tests__/exportServerPlugin.ts @@ -0,0 +1,112 @@ +// Import mocks first +import { jestWorkerId } from '../../testHelpers/jestWorkerId.js'; +import { mockConsole } from '../../testHelpers/mockConsole.js'; +import { mockCosmosPlugins } from '../../testHelpers/mockCosmosPlugins.js'; +import '../../testHelpers/mockEsmRequire.js'; +import '../../testHelpers/mockEsmResolve.js'; +import '../../testHelpers/mockEsmStaticPath.js'; +import { + mockCosmosConfig, + mockFile, + resetFsMock, +} from '../../testHelpers/mockFs.js'; +import { mockCliArgs, unmockCliArgs } from '../../testHelpers/mockYargs.js'; + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { generateExport } from '../generateExport.js'; + +const testCosmosPlugin = { + name: 'Test Cosmos plugin', + rootDir: path.join(__dirname, 'mock-cosmos-plugin'), + server: path.join(__dirname, 'mock-cosmos-plugin/server.js'), +}; +mockCosmosPlugins([testCosmosPlugin]); + +const asyncMock = jest.fn(); +const testServerPlugin = { + name: 'testServerPlugin', + + config: jest.fn(async ({ cosmosConfig }) => { + return { + ...cosmosConfig, + ignore: ['**/ignored.fixture.js'], + }; + }), + + export: jest.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + asyncMock(); + }), +}; + +const port = 5000 + jestWorkerId(); + +const testFsPath = path.join(__dirname, '../__testFs__'); +const exportPath = path.join(testFsPath, `export-${jestWorkerId()}`); + +beforeEach(() => { + mockCliArgs({}); + mockCosmosConfig('cosmos.config.json', { port, exportPath }); + mockFile(testCosmosPlugin.server, { default: testServerPlugin }); + + asyncMock.mockClear(); + testServerPlugin.config.mockClear(); + testServerPlugin.export.mockClear(); +}); + +afterEach(async () => { + unmockCliArgs(); + resetFsMock(); + await fs.rm(exportPath, { recursive: true, force: true }); +}); + +it('calls config hook', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Export complete!'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`Export path: ${exportPath}`); + + await generateExport(); + + expect(testServerPlugin.config).toBeCalledWith({ + cosmosConfig: expect.objectContaining({ exportPath }), + platformType: 'web', + }); + }); +}); + +it('calls export hook (with updated config)', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Export complete!'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`Export path: ${exportPath}`); + + await generateExport(); + + expect(testServerPlugin.export).toBeCalledWith({ + cosmosConfig: expect.objectContaining({ + exportPath, + ignore: ['**/ignored.fixture.js'], + }), + }); + expect(asyncMock).toBeCalled(); + }); +}); + +it('does not embed server-only plugins in playground HTML', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Export complete!'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`Export path: ${exportPath}`); + + await generateExport(); + + const html = await readExportFile('index.html'); + expect(html).toContain(`"pluginConfigs":[]`); + }); +}); + +async function readExportFile(filePath: string) { + return fs.readFile(path.join(exportPath, filePath), 'utf8'); +} diff --git a/packages/react-cosmos/src/server/export/__tests__/exportUiPlugin.ts b/packages/react-cosmos/src/server/export/__tests__/exportUiPlugin.ts new file mode 100644 index 0000000000..64ea4243a7 --- /dev/null +++ b/packages/react-cosmos/src/server/export/__tests__/exportUiPlugin.ts @@ -0,0 +1,82 @@ +// Import mocks first +import { jestWorkerId } from '../../testHelpers/jestWorkerId.js'; +import { mockConsole } from '../../testHelpers/mockConsole.js'; +import { mockCosmosPlugins } from '../../testHelpers/mockCosmosPlugins.js'; +import '../../testHelpers/mockEsmRequire.js'; +import '../../testHelpers/mockEsmResolve.js'; +import '../../testHelpers/mockEsmStaticPath.js'; +import { mockCosmosConfig, resetFsMock } from '../../testHelpers/mockFs.js'; +import { mockCliArgs, unmockCliArgs } from '../../testHelpers/mockYargs.js'; + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { generateExport } from '../generateExport.js'; + +const port = 5000 + jestWorkerId(); + +const testFsPath = path.join(__dirname, '../__testFs__'); +const pluginPath = path.join(testFsPath, `plugin-${jestWorkerId()}`); +const exportPath = path.join(testFsPath, `export-${jestWorkerId()}`); + +const testCosmosPlugin = { + name: 'Test Cosmos plugin', + rootDir: pluginPath, + ui: path.join(pluginPath, 'ui.js'), +}; +mockCosmosPlugins([testCosmosPlugin]); + +beforeEach(async () => { + mockCliArgs({}); + mockCosmosConfig('cosmos.config.json', { port, exportPath }); + + await fs.mkdir(testCosmosPlugin.rootDir, { recursive: true }); + await fs.writeFile(testCosmosPlugin.ui, 'export {}', 'utf8'); +}); + +afterEach(async () => { + unmockCliArgs(); + resetFsMock(); + await fs.rm(pluginPath, { recursive: true, force: true }); + await fs.rm(exportPath, { recursive: true, force: true }); +}); + +it('embeds UI plugin in playground HTML', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Export complete!'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`Export path: ${exportPath}`); + + await generateExport(); + + const html = await readExportFile('index.html'); + expect(html).toContain( + JSON.stringify([ + { + name: 'Test Cosmos plugin', + // Paths are relative to the export directory + rootDir: `plugin-${jestWorkerId()}`, + ui: path.join(`plugin-${jestWorkerId()}`, 'ui.js'), + }, + ]) + ); + }); +}); + +it('copies plugin files to export directory', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Export complete!'); + expectLog('[Cosmos] Found 1 plugin: Test Cosmos plugin'); + expectLog(`Export path: ${exportPath}`); + + await generateExport(); + + const uiPath = path.join(`_plugin/plugin-${jestWorkerId()}/ui.js`); + const uiModule = await readExportFile(uiPath); + + expect(uiModule).toBe('export {}'); + }); +}); + +async function readExportFile(filePath: string) { + return fs.readFile(path.join(exportPath, filePath), 'utf8'); +} diff --git a/packages/react-cosmos/src/server/export/__tests__/generateExport.ts b/packages/react-cosmos/src/server/export/__tests__/generateExport.ts new file mode 100644 index 0000000000..6bd7916ffc --- /dev/null +++ b/packages/react-cosmos/src/server/export/__tests__/generateExport.ts @@ -0,0 +1,95 @@ +// Import mocks first +import { jestWorkerId } from '../../testHelpers/jestWorkerId.js'; +import { mockConsole } from '../../testHelpers/mockConsole.js'; +import { mockCosmosPlugins } from '../../testHelpers/mockCosmosPlugins.js'; +import '../../testHelpers/mockEsmRequire.js'; +import '../../testHelpers/mockEsmResolve.js'; +import '../../testHelpers/mockEsmStaticPath.js'; +import { mockCosmosConfig, resetFsMock } from '../../testHelpers/mockFs.js'; +import { mockCliArgs, unmockCliArgs } from '../../testHelpers/mockYargs.js'; + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { generateExport } from '../generateExport.js'; + +mockCosmosPlugins([]); + +const port = 5000 + jestWorkerId(); + +const testFsPath = path.join(__dirname, '../__testFs__'); +const exportPath = path.join(testFsPath, `export-${jestWorkerId()}`); + +beforeEach(() => { + mockCliArgs({}); + mockCosmosConfig('cosmos.config.json', { port, exportPath }); +}); + +afterEach(async () => { + unmockCliArgs(); + resetFsMock(); + await fs.rm(exportPath, { recursive: true, force: true }); +}); + +it('generates playground HTML', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Export complete!'); + expectLog(`Export path: ${exportPath}`); + + await generateExport(); + + const html = await readExportFile('index.html'); + + expect(html).toContain('React Cosmos'); + expect(html).toContain(''); + + expect(html).toContain( + JSON.stringify({ + playgroundConfig: { + core: { + projectId: 'new-project', + fixturesDir: '__fixtures__', + fixtureFileSuffix: 'fixture', + devServerOn: false, + webRendererUrl: '/_renderer.html', + }, + }, + pluginConfigs: [], + }) + ); + }); +}); + +it('generates playground JS', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Export complete!'); + expectLog(`Export path: ${exportPath}`); + + await generateExport(); + + const bundle = await readExportFile('playground.bundle.js'); + expect(bundle.trim()).toBe('__mock_bundle__'); + + const sourceMap = await readExportFile('playground.bundle.js.map'); + expect(sourceMap.trim()).toBe('__mock_map__'); + }); +}); + +it('generates favicon', async () => { + return mockConsole(async ({ expectLog }) => { + expectLog('[Cosmos] Export complete!'); + expectLog(`Export path: ${exportPath}`); + + await generateExport(); + + expect(await readExportFile('_cosmos.ico')).toBe( + await fs.readFile( + path.join(__dirname, '../../static/favicon.ico'), + 'utf8' + ) + ); + }); +}); + +async function readExportFile(filePath: string) { + return fs.readFile(path.join(exportPath, filePath), 'utf8'); +} diff --git a/packages/react-cosmos/src/server/export/generateExport.ts b/packages/react-cosmos/src/server/export/generateExport.ts index caa03226a9..59bc1129a7 100644 --- a/packages/react-cosmos/src/server/export/generateExport.ts +++ b/packages/react-cosmos/src/server/export/generateExport.ts @@ -5,20 +5,19 @@ import { removeLeadingSlash, UiCosmosPluginConfig, } from 'react-cosmos-core'; +import { coreServerPlugins } from '../corePlugins/index.js'; import { detectCosmosConfig } from '../cosmosConfig/detectCosmosConfig.js'; import { CosmosConfig } from '../cosmosConfig/types.js'; import { getPluginConfigs } from '../cosmosPlugin/pluginConfigs.js'; -import { ExportPlugin } from '../cosmosPlugin/types.js'; +import { importServerPlugins } from '../shared/importServerPlugins.js'; import { logPluginInfo } from '../shared/logPluginInfo.js'; import { getExportPlaygroundHtml } from '../shared/playgroundHtml.js'; -import { requirePluginModule } from '../shared/requirePluginModule.js'; import { getStaticPath } from '../shared/staticPath.js'; import { resolve } from '../utils/resolve.js'; -const builtInPlugins: ExportPlugin[] = []; - export async function generateExport() { - const cosmosConfig = await detectCosmosConfig(); + const platformType = 'web'; + let cosmosConfig = await detectCosmosConfig(); const pluginConfigs = await getPluginConfigs({ cosmosConfig, @@ -28,24 +27,39 @@ export async function generateExport() { }); logPluginInfo(pluginConfigs); + const userPlugins = await importServerPlugins( + pluginConfigs, + cosmosConfig.rootDir + ); + const plugins = [...coreServerPlugins, ...userPlugins]; + + for (const plugin of plugins) { + if (plugin.config) { + try { + cosmosConfig = await plugin.config({ cosmosConfig, platformType }); + } catch (err) { + console.log(`[Cosmos][plugin:${plugin.name}] Config hook failed`); + throw err; + } + } + } + // Clear previous export (or other files at export path) const { exportPath } = cosmosConfig; await fs.rm(exportPath, { recursive: true, force: true }); + await fs.mkdir(exportPath, { recursive: true }); // Copy static assets first, so that the built index.html overrides the its // template file (in case the static assets are served from the root path) await copyStaticAssets(cosmosConfig); - const userPlugins = await getExportPlugins( - pluginConfigs, - cosmosConfig.rootDir - ); + for (const plugin of plugins) { + if (!plugin.export) continue; - for (const plugin of [...builtInPlugins, ...userPlugins]) { try { - await plugin({ cosmosConfig }); + await plugin.export({ cosmosConfig }); } catch (err) { - console.log(`[Cosmos][${plugin.name}] Export plugin failed`); + console.log(`[Cosmos][plugin:${plugin.name}] Export failed`); throw err; } } @@ -89,19 +103,6 @@ async function copyStaticAssets(cosmosConfig: CosmosConfig) { fs.cp(staticPath, exportStaticPath, { recursive: true }); } -async function getExportPlugins( - pluginConfigs: CosmosPluginConfig[], - rootDir: string -) { - return Promise.all( - pluginConfigs - .filter(pluginConfig => pluginConfig.export) - .map(pluginConfig => - requirePluginModule(rootDir, pluginConfig, 'export') - ) - ); -} - async function exportPlaygroundFiles( cosmosConfig: CosmosConfig, pluginConfigs: CosmosPluginConfig[] diff --git a/packages/react-cosmos/src/server/shared/findNextAvailablePort.ts b/packages/react-cosmos/src/server/shared/findNextAvailablePort.ts new file mode 100644 index 0000000000..49cae965a6 --- /dev/null +++ b/packages/react-cosmos/src/server/shared/findNextAvailablePort.ts @@ -0,0 +1,35 @@ +import net from 'net'; + +// Inspired by https://stackoverflow.com/q/19129570 +export async function findNextAvailablePort( + port: number, + retries: number, + retriesLeft = retries +) { + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + + socket.on('connect', () => { + console.log(`[Cosmos] Port ${port} already in use, trying next...`); + if (retriesLeft > 0) { + socket.destroy(); + resolve(findNextAvailablePort(port + 1, retries, retriesLeft - 1)); + } else { + reject(`No available port found after ${retries} retries.`); + } + }); + + socket.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'ECONNREFUSED') resolve(port); + else reject(error); + }); + + socket.setTimeout(400); + socket.on('timeout', () => { + socket.destroy(); + resolve(port); + }); + + socket.connect(port, '0.0.0.0'); + }); +} diff --git a/packages/react-cosmos/src/server/shared/importServerPlugins.ts b/packages/react-cosmos/src/server/shared/importServerPlugins.ts new file mode 100644 index 0000000000..31cc98502f --- /dev/null +++ b/packages/react-cosmos/src/server/shared/importServerPlugins.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; +import { CosmosPluginConfig } from 'react-cosmos-core'; +import { CosmosServerPlugin } from '../cosmosPlugin/types.js'; +import { importModule } from '../utils/fs.js'; + +export async function importServerPlugins( + pluginConfigs: CosmosPluginConfig[], + rootDir: string +) { + return Promise.all( + pluginConfigs + .filter(pluginConfig => pluginConfig.server) + .map(pluginConfig => importServerModule(pluginConfig, rootDir)) + ); +} + +async function importServerModule( + pluginConfig: CosmosPluginConfig, + rootDir: string +) { + const modulePath = pluginConfig.server; + if (!modulePath) { + throw new Error(`Server module missing in plugin "${pluginConfig.name}"`); + } + + const absPath = path.resolve(rootDir, modulePath); + const module = await importModule<{ default: CosmosServerPlugin }>(absPath); + return module.default; +} diff --git a/packages/react-cosmos/src/server/shared/requirePluginModule.ts b/packages/react-cosmos/src/server/shared/requirePluginModule.ts deleted file mode 100644 index 2751d24f45..0000000000 --- a/packages/react-cosmos/src/server/shared/requirePluginModule.ts +++ /dev/null @@ -1,21 +0,0 @@ -import path from 'path'; -import { CosmosPluginConfig } from 'react-cosmos-core'; -import { pathToFileURL } from 'url'; - -export async function requirePluginModule( - rootDir: string, - pluginConfig: CosmosPluginConfig, - configKey: 'ui' | 'devServer' | 'export' -) { - const moduleId = pluginConfig[configKey]; - if (!moduleId) { - throw new Error( - `Module missing in plugin "${pluginConfig.name}": ${configKey}` - ); - } - - const esModule = await import( - pathToFileURL(path.resolve(rootDir, moduleId)).href - ); - return esModule.default as T; -} diff --git a/packages/react-cosmos/src/server/testHelpers/jestWorkerId.ts b/packages/react-cosmos/src/server/testHelpers/jestWorkerId.ts new file mode 100644 index 0000000000..c3705c16f1 --- /dev/null +++ b/packages/react-cosmos/src/server/testHelpers/jestWorkerId.ts @@ -0,0 +1,3 @@ +export function jestWorkerId() { + return parseInt(process.env.JEST_WORKER_ID || '1', 10); +} diff --git a/packages/react-cosmos/src/server/testHelpers/mockConsole.ts b/packages/react-cosmos/src/server/testHelpers/mockConsole.ts index 240cc4e79e..af8f2e336f 100644 --- a/packages/react-cosmos/src/server/testHelpers/mockConsole.ts +++ b/packages/react-cosmos/src/server/testHelpers/mockConsole.ts @@ -5,15 +5,21 @@ type ConsoleMockApi = { export async function mockConsole( cb: (api: ConsoleMockApi) => Promise ): Promise { + const expectedLogs: string[] = []; + const origConsoleLog = console.log; - console.log = jest.fn(); + console.log = jest.fn((...args: unknown[]) => { + if (typeof args[0] !== 'string' || !expectedLogs.includes(args[0])) { + origConsoleLog(...args); + } + }); - const expectedLogs: string[] = []; - const ret = await cb({ + const cbReturn = await cb({ expectLog: (msg: string) => expectedLogs.push(msg), }); expectedLogs.forEach(msg => expect(console.log).toBeCalledWith(msg)); console.log = origConsoleLog; - return ret; + + return cbReturn; } diff --git a/packages/react-cosmos/src/server/testHelpers/mockCosmosPlugins.ts b/packages/react-cosmos/src/server/testHelpers/mockCosmosPlugins.ts new file mode 100644 index 0000000000..22e2b2790a --- /dev/null +++ b/packages/react-cosmos/src/server/testHelpers/mockCosmosPlugins.ts @@ -0,0 +1,23 @@ +import { CosmosPluginConfig } from 'react-cosmos-core'; + +jest.mock('../cosmosPlugin/findCosmosPluginConfigs.js', () => { + let pluginConfigs: CosmosPluginConfig[] = []; + + return { + async findCosmosPluginConfigs() { + return Promise.resolve(pluginConfigs); + }, + + __mockCosmosPluginConfigs(configs: CosmosPluginConfig[]) { + pluginConfigs = configs; + }, + }; +}); + +export function mockCosmosPlugins(configs: CosmosPluginConfig[]) { + requireMocked().__mockCosmosPluginConfigs(configs); +} + +function requireMocked() { + return require('../cosmosPlugin/findCosmosPluginConfigs.js'); +} diff --git a/packages/react-cosmos/src/server/testHelpers/mockFs.ts b/packages/react-cosmos/src/server/testHelpers/mockFs.ts index 45ccb1f050..70548b4941 100644 --- a/packages/react-cosmos/src/server/testHelpers/mockFs.ts +++ b/packages/react-cosmos/src/server/testHelpers/mockFs.ts @@ -1,4 +1,4 @@ -import path from 'path'; +import path from 'node:path'; import { CosmosConfig } from '../cosmosConfig/types.js'; import { getCwdPath } from './cwd.js'; @@ -6,15 +6,32 @@ jest.mock('../utils/fs', () => { const actual = jest.requireActual('../utils/fs'); let mocked = false; - let fileMocks: { [path: string]: any } = {}; + let fileMocks: { [path: string]: {} } = {}; let dirMocks: string[] = []; async function importModule(moduleId: string) { if (!mocked) return actual.importModule(moduleId); + if ( + !fileMocks.hasOwnProperty(moduleId) && + !fileMocks.hasOwnProperty(`${moduleId}.js`) + ) { + throw new Error(`Cannot find module '${moduleId}'`); + } + return fileMocks[moduleId] || fileMocks[`${moduleId}.js`]; } + async function importJson(filePath: string) { + if (!mocked) return actual.importJson(filePath); + + if (!fileMocks.hasOwnProperty(filePath)) { + throw new Error(`Cannot find JSON '${filePath}'`); + } + + return fileMocks[filePath]; + } + function moduleExists(moduleId: string) { if (!mocked) return actual.moduleExists(moduleId); @@ -38,15 +55,21 @@ jest.mock('../utils/fs', () => { return { importModule, + importJson, moduleExists, fileExists, dirExists, - __mockFile(filePath: string, fileMock: any) { + __mockFile(filePath: string, fileMock: {}) { mocked = true; fileMocks = { ...fileMocks, [filePath]: fileMock }; }, + __mockJson(filePath: string, jsonMock: {}) { + mocked = true; + fileMocks = { ...fileMocks, [filePath]: jsonMock }; + }, + __mockDir(dirPath: string) { mocked = true; dirMocks = [...dirMocks, dirPath]; @@ -59,21 +82,22 @@ jest.mock('../utils/fs', () => { }; }); +export function mockFile(filePath: string, fileMock: {}) { + requireMocked().__mockFile(filePath, fileMock); + requireMocked().__mockDir(path.dirname(filePath)); +} + export function mockCosmosConfig( cosmosConfigPath: string, cosmosConfig: Partial ) { - mockFile(cosmosConfigPath, cosmosConfig); + const absPath = getCwdPath(cosmosConfigPath); + mockFile(absPath, cosmosConfig); } -export function mockFile(filePath: string, fileContent: {}) { +export function mockCwdModuleDefault(filePath: string, fileMock: {}) { const absPath = getCwdPath(filePath); - requireMocked().__mockFile(absPath, fileContent); - requireMocked().__mockDir(path.dirname(absPath)); -} - -export function mockDir(dirPath: string) { - requireMocked().__mockDir(getCwdPath(dirPath)); + mockFile(absPath, { default: fileMock }); } export function resetFsMock() { diff --git a/packages/react-cosmos/src/server/testMocks/playground.bundle.js b/packages/react-cosmos/src/server/testMocks/playground.bundle.js new file mode 100644 index 0000000000..660d7bf9eb --- /dev/null +++ b/packages/react-cosmos/src/server/testMocks/playground.bundle.js @@ -0,0 +1 @@ +__mock_bundle__ diff --git a/packages/react-cosmos/src/server/testMocks/playground.bundle.js.map b/packages/react-cosmos/src/server/testMocks/playground.bundle.js.map new file mode 100644 index 0000000000..9f9b427e23 --- /dev/null +++ b/packages/react-cosmos/src/server/testMocks/playground.bundle.js.map @@ -0,0 +1 @@ +__mock_map__ diff --git a/packages/react-cosmos/src/server/userDeps/fixtureWatcher.tsx b/packages/react-cosmos/src/server/userDeps/fixtureWatcher.ts similarity index 100% rename from packages/react-cosmos/src/server/userDeps/fixtureWatcher.tsx rename to packages/react-cosmos/src/server/userDeps/fixtureWatcher.ts diff --git a/packages/react-cosmos/src/server/utils/fs.ts b/packages/react-cosmos/src/server/utils/fs.ts index 47a54d015b..ea8dec12cd 100644 --- a/packages/react-cosmos/src/server/utils/fs.ts +++ b/packages/react-cosmos/src/server/utils/fs.ts @@ -1,13 +1,17 @@ // NOTE: This API has been extracted to easily mock the file system in tests -import fs from 'fs'; +import fs from 'node:fs'; +import url from 'node:url'; import { requireModule } from './requireModule.js'; import { resolve } from './resolve.js'; -export async function importModule(moduleId: string): Promise { - return moduleId.endsWith('.json') - ? requireModule(moduleId) - : import(moduleId); +export async function importModule(filePath: string): Promise { + const fileUrl = url.pathToFileURL(filePath).href; + return import(fileUrl); +} + +export async function importJson(filePath: string): Promise { + return requireModule(filePath); } // Better than fs.exists because it works for module paths without an extension diff --git a/tsconfig.build.json b/tsconfig.build.json index 30f6b78a5b..da27e19c56 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -13,6 +13,7 @@ "**/*.test.tsx", "**/*.fixture.ts", "**/*.fixture.tsx", - "**/testHelpers/**/*" + "**/testHelpers/**/*", + "**/testMocks/**/*" ] } diff --git a/yarn.lock b/yarn.lock index bcb7ff86a0..77d68196c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1741,6 +1741,11 @@ dependencies: "@types/node" "*" +"@types/isomorphic-fetch@^0.0.36": + version "0.0.36" + resolved "https://registry.yarnpkg.com/@types/isomorphic-fetch/-/isomorphic-fetch-0.0.36.tgz#3a6d8f63974baf26b10fd1314db910633108a769" + integrity sha512-ulw4d+vW1HKn4oErSmNN2HYEcHGq0N1C5exlrMM0CRqX1UUpFhGb5lwiom5j9KN3LBJJDLRmYIZz1ghm7FIzZw== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"