diff --git a/packages/react-cosmos-core/src/fixtureTree/createFixtureTree/createRawFixtureTree.ts b/packages/react-cosmos-core/src/fixtureTree/createFixtureTree/createRawFixtureTree.ts index 0e7f92090c..ce1525267f 100644 --- a/packages/react-cosmos-core/src/fixtureTree/createFixtureTree/createRawFixtureTree.ts +++ b/packages/react-cosmos-core/src/fixtureTree/createFixtureTree/createRawFixtureTree.ts @@ -4,6 +4,7 @@ import { FixtureListItem, } from '../../userModules/fixtureTypes.js'; import { addTreeNodeChild } from '../../utils/tree.js'; +import { removeFixtureNameExtension } from '../fixtureUtils.js'; import { FixtureTreeNode } from '../types.js'; export function createRawFixtureTree(fixtures: FixtureList): FixtureTreeNode { @@ -55,10 +56,6 @@ function parseFixturePath(fixturePath: string) { }; } -function removeFixtureNameExtension(fixtureName: string) { - return fixtureName.replace(/\.(js|jsx|ts|tsx|md|mdx)$/, ''); -} - function injectNode( rootNode: FixtureTreeNode, parents: string[], diff --git a/packages/react-cosmos-core/src/fixtureTree/createFixtureTree/hideFixtureSuffix.ts b/packages/react-cosmos-core/src/fixtureTree/createFixtureTree/hideFixtureSuffix.ts index cd87345a60..1b79ae0997 100644 --- a/packages/react-cosmos-core/src/fixtureTree/createFixtureTree/hideFixtureSuffix.ts +++ b/packages/react-cosmos-core/src/fixtureTree/createFixtureTree/hideFixtureSuffix.ts @@ -1,3 +1,4 @@ +import { removeFixtureNameSuffix } from '../fixtureUtils.js'; import { FixtureTreeNode } from '../types.js'; export function hideFixtureSuffix( @@ -25,10 +26,3 @@ export function hideFixtureSuffix( }, {}), }; } - -function removeFixtureNameSuffix( - fixtureNameWithoutExtension: string, - suffix: string -) { - return fixtureNameWithoutExtension.replace(new RegExp(`\\.${suffix}$`), ''); -} diff --git a/packages/react-cosmos-core/src/fixtureTree/fixtureUtils.ts b/packages/react-cosmos-core/src/fixtureTree/fixtureUtils.ts new file mode 100644 index 0000000000..ebff904723 --- /dev/null +++ b/packages/react-cosmos-core/src/fixtureTree/fixtureUtils.ts @@ -0,0 +1,10 @@ +export function removeFixtureNameExtension(fixtureName: string) { + return fixtureName.replace(/\.(js|jsx|ts|tsx|md|mdx)$/, ''); +} + +export function removeFixtureNameSuffix( + fixtureNameWithoutExtension: string, + suffix: string +) { + return fixtureNameWithoutExtension.replace(new RegExp(`\\.${suffix}$`), ''); +} diff --git a/packages/react-cosmos-core/src/index.ts b/packages/react-cosmos-core/src/index.ts index d936cb5ece..476538f031 100644 --- a/packages/react-cosmos-core/src/index.ts +++ b/packages/react-cosmos-core/src/index.ts @@ -9,6 +9,7 @@ export * from './fixtureState/propsTypes.js'; export * from './fixtureState/types.js'; export * from './fixtureState/viewport.js'; export * from './fixtureTree/createFixtureTree/index.js'; +export * from './fixtureTree/fixtureUtils.js'; export * from './fixtureTree/flattenFixtureTree.js'; export * from './fixtureTree/types.js'; export * from './message/serverMessage.js'; diff --git a/packages/react-cosmos-core/src/renderer/rendererUrl.ts b/packages/react-cosmos-core/src/renderer/rendererUrl.ts index d7f11fbb3c..451fc4a55c 100644 --- a/packages/react-cosmos-core/src/renderer/rendererUrl.ts +++ b/packages/react-cosmos-core/src/renderer/rendererUrl.ts @@ -3,6 +3,8 @@ import { CosmosCommand } from '../server/serverTypes.js'; import { FixtureId } from '../userModules/fixtureTypes.js'; import { buildRendererQueryString } from './rendererQueryString.js'; +export type CosmosRendererUrl = null | string | { dev: string; export: string }; + export function createRendererUrl( rendererUrl: string, fixtureId?: FixtureId, @@ -24,7 +26,7 @@ export function createRendererUrl( } export function pickRendererUrl( - rendererUrl: undefined | null | string | { dev: string; export: string }, + rendererUrl: undefined | CosmosRendererUrl, command: CosmosCommand ): null | string { return rendererUrl && typeof rendererUrl === 'object' diff --git a/packages/react-cosmos/src/corePlugins/fixturesJsonPlugin.ts b/packages/react-cosmos/src/corePlugins/fixturesJsonPlugin.ts new file mode 100644 index 0000000000..1b23189a8a --- /dev/null +++ b/packages/react-cosmos/src/corePlugins/fixturesJsonPlugin.ts @@ -0,0 +1,91 @@ +import express from 'express'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + CosmosCommand, + createRendererUrl, + pickRendererUrl, + removeFixtureNameExtension, + removeFixtureNameSuffix, +} from 'react-cosmos-core'; +import { CosmosConfig } from '../cosmosConfig/types.js'; +import { CosmosServerPlugin } from '../cosmosPlugin/types.js'; +import { findUserModulePaths } from '../userModules/findUserModulePaths.js'; +import { importKeyPath } from '../userModules/shared.js'; + +export type CosmosFixtureJson = { + filePath: string; + cleanPath: string[]; + rendererUrl: string; +}; + +export type CosmosFixturesJson = { + rendererUrl: string | null; + fixtures: CosmosFixtureJson[]; +}; + +export const fixturesJsonPlugin: CosmosServerPlugin = { + name: 'fixturesJson', + + devServer({ cosmosConfig, expressApp }) { + expressApp.get( + '/cosmos.fixtures.json', + (req: express.Request, res: express.Response) => { + res.json(createFixtureItems(cosmosConfig, 'dev')); + } + ); + }, + + async export({ cosmosConfig }) { + const { exportPath } = cosmosConfig; + const json = createFixtureItems(cosmosConfig, 'export'); + await fs.writeFile( + path.join(exportPath, 'cosmos.fixtures.json'), + JSON.stringify(json, null, 2) + ); + }, +}; + +function createFixtureItems( + cosmosConfig: CosmosConfig, + command: CosmosCommand +): CosmosFixturesJson { + const rendererUrl = pickRendererUrl(cosmosConfig.rendererUrl, command); + if (!rendererUrl) { + return { + rendererUrl: null, + fixtures: [], + }; + } + + const { fixturesDir, fixtureFileSuffix } = cosmosConfig; + const { fixturePaths } = findUserModulePaths(cosmosConfig); + + return { + rendererUrl, + fixtures: fixturePaths.map(filePath => { + const relPath = importKeyPath(filePath, cosmosConfig.rootDir); + const fixtureId = { path: relPath }; + return { + filePath: relPath, + cleanPath: cleanFixturePath(relPath, fixturesDir, fixtureFileSuffix), + rendererUrl: createRendererUrl(rendererUrl, fixtureId, true), + }; + }), + }; +} + +function cleanFixturePath( + filePath: string, + fixturesDir: string, + fixtureSuffix: string +) { + const paths = filePath.split('/').filter(p => p !== fixturesDir); + return [ + ...paths.slice(0, -1), + removeFixtureNameSuffix( + removeFixtureNameExtension(paths[paths.length - 1]), + fixtureSuffix + ), + ]; +} diff --git a/packages/react-cosmos/src/corePlugins/index.ts b/packages/react-cosmos/src/corePlugins/index.ts index 0b4617bfce..7b46b29112 100644 --- a/packages/react-cosmos/src/corePlugins/index.ts +++ b/packages/react-cosmos/src/corePlugins/index.ts @@ -1,5 +1,6 @@ import { CosmosServerPlugin } from '../cosmosPlugin/types.js'; import { fixtureWatcherPlugin } from './fixtureWatcherPlugin.js'; +import { fixturesJsonPlugin } from './fixturesJsonPlugin.js'; import { httpProxyPlugin } from './httpProxyPlugin.js'; import { openFilePlugin } from './openFilePlugin.js'; import { pluginEndpointPlugin } from './pluginEndpointPlugin.js'; @@ -7,6 +8,7 @@ import { portRetryPlugin } from './portRetryPlugin.js'; export const coreServerPlugins: CosmosServerPlugin[] = [ portRetryPlugin, + fixturesJsonPlugin, httpProxyPlugin, openFilePlugin, pluginEndpointPlugin, diff --git a/packages/react-cosmos/src/cosmosConfig/types.ts b/packages/react-cosmos/src/cosmosConfig/types.ts index 8478722b8d..1a162f4f57 100644 --- a/packages/react-cosmos/src/cosmosConfig/types.ts +++ b/packages/react-cosmos/src/cosmosConfig/types.ts @@ -1,3 +1,5 @@ +import { CosmosRendererUrl } from 'react-cosmos-core'; + interface HttpsOptions { keyPath: string; certPath: string; @@ -34,7 +36,7 @@ export type CosmosConfig = { portRetries: number; plugins: string[]; publicUrl: string; - rendererUrl: null | string | { dev: string; export: string }; + rendererUrl: CosmosRendererUrl; rootDir: string; staticPath: null | string; watchDirs: string[]; diff --git a/packages/react-cosmos/src/getFixtures/importUserModules.ts b/packages/react-cosmos/src/getFixtures/importUserModules.ts index 53a1b108c9..7ab465f154 100644 --- a/packages/react-cosmos/src/getFixtures/importUserModules.ts +++ b/packages/react-cosmos/src/getFixtures/importUserModules.ts @@ -1,4 +1,3 @@ -import path from 'path'; import { ByPath, ReactDecoratorModule, @@ -6,7 +5,7 @@ import { } from 'react-cosmos-core'; import { CosmosConfig } from '../cosmosConfig/types.js'; import { findUserModulePaths } from '../userModules/findUserModulePaths.js'; -import { slash } from '../utils/slash.js'; +import { importKeyPath } from '../userModules/shared.js'; type UserModules = { fixtures: ByPath; @@ -33,10 +32,7 @@ export function importUserModules({ function importModules(paths: string[], rootDir: string) { const modules = paths.map(p => { - // Converting to forward slashes on Windows is important because the - // slashes are used for generating a sorted list of fixtures and - // decorators. - const relPath = slash(path.relative(rootDir, p)); + const relPath = importKeyPath(p, rootDir); return { relPath, module: require(p) }; }); diff --git a/packages/react-cosmos/src/index.ts b/packages/react-cosmos/src/index.ts index e89ce46568..8392895312 100644 --- a/packages/react-cosmos/src/index.ts +++ b/packages/react-cosmos/src/index.ts @@ -1,3 +1,7 @@ +export { + CosmosFixtureJson, + CosmosFixturesJson, +} from './corePlugins/fixturesJsonPlugin.js'; export * from './cosmosConfig/createCosmosConfig.js'; export * from './cosmosConfig/detectCosmosConfig.js'; export * from './cosmosConfig/getCosmosConfigAtPath.js'; diff --git a/packages/react-cosmos/src/userModules/shared.ts b/packages/react-cosmos/src/userModules/shared.ts index f4d3e2a6d9..eb49b30274 100644 --- a/packages/react-cosmos/src/userModules/shared.ts +++ b/packages/react-cosmos/src/userModules/shared.ts @@ -54,6 +54,9 @@ export function createImportMap( } export function importKeyPath(filePath: string, rootDir: string) { + // Converting to forward slashes on Windows is important because the + // slashes are used for generating a sorted list of fixtures and + // decorators. return slash(path.relative(rootDir, filePath)); } diff --git a/tests/helpers/webTests.ts b/tests/helpers/webTests.ts index 870c873c65..2b698ba528 100644 --- a/tests/helpers/webTests.ts +++ b/tests/helpers/webTests.ts @@ -1,4 +1,5 @@ -import { Page, expect, test } from '@playwright/test'; +import { APIRequestContext, Page, expect, test } from '@playwright/test'; +import { CosmosFixtureJson, CosmosFixturesJson } from 'react-cosmos'; import { exampleName } from './envVars.js'; export function webTests(url: string) { @@ -85,8 +86,52 @@ export function webTests(url: string) { expect(await response.text()).toContain('nom nom nom'); }); }); + + test.describe('cosmos.fixture.json', () => { + test('contains renderer URL', async ({ request }) => { + const { rendererUrl } = await getFixturesJson(request, url); + expect(typeof rendererUrl).toBe('string'); + }); + + test('contains fixture data', async ({ request }) => { + const { fixtures } = await getFixturesJson(request, url); + expect(fixtures).toContainEqual({ + filePath: 'src/WelcomeMessage/WelcomeMessage.fixture.tsx', + cleanPath: ['src', 'WelcomeMessage', 'WelcomeMessage'], + rendererUrl: expect.stringContaining( + '?fixtureId=%7B%22path%22%3A%22src%2FWelcomeMessage%2FWelcomeMessage.fixture.tsx%22%7D&locked=true' + ), + }); + }); + + test('contains fixture renderer URL', async ({ request, page }) => { + const { fixtures } = await getFixturesJson(request, url); + const fixture = expectFixture(fixtures, 'HelloWorld.mdx'); + await page.goto(resolveRendererUrl(url, fixture.rendererUrl)); + await expect(page.getByText('Hello World!')).toBeVisible(); + }); + }); } function rendererRoot(page: Page) { return page.frameLocator('iframe').locator('#root'); } + +async function getFixturesJson(request: APIRequestContext, url: string) { + const response = await request.get(url + '/cosmos.fixtures.json'); + return (await response.json()) as CosmosFixturesJson; +} + +function expectFixture(fixtures: CosmosFixtureJson[], fileName: string) { + const fixture = fixtures.find(f => f.filePath.endsWith(fileName)); + expect(fixture).toBeTruthy(); + return fixture!; +} + +function resolveRendererUrl(url: string, rendererUrl: string) { + try { + return new URL(rendererUrl).href; + } catch (err) { + return new URL(rendererUrl, url).href; + } +}