diff --git a/.changelog/20260710160000_ci_4265.md b/.changelog/20260710160000_ci_4265.md new file mode 100644 index 000000000..2beb67045 --- /dev/null +++ b/.changelog/20260710160000_ci_4265.md @@ -0,0 +1,9 @@ +--- +type: Feature +scope: + - ckeditor5-dev-manual-server +see: + - ckeditor/ckeditor5#17102 +--- + +The `manualTestsPlugin()` plugin now loads the package theme entry stylesheet (`theme/index.css`) in manual tests. Package stylesheets are imported by the package entry module (`src/index.ts`), not by individual source modules, so manual tests that import source modules directly would otherwise render without the package's own styles. The import is appended to the entry script of every discovered manual page whose package has a theme entry stylesheet, preserving the cascade order of the built bundles. diff --git a/packages/ckeditor5-dev-manual-server/src/manual-test-plugin/plugin.ts b/packages/ckeditor5-dev-manual-server/src/manual-test-plugin/plugin.ts index 5c70e2966..73e3c0009 100644 --- a/packages/ckeditor5-dev-manual-server/src/manual-test-plugin/plugin.ts +++ b/packages/ckeditor5-dev-manual-server/src/manual-test-plugin/plugin.ts @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md. */ -import { readFileSync, realpathSync } from 'node:fs'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { posix, resolve, relative } from 'node:path'; import { collectManualPages } from './collect-pages.js'; @@ -25,6 +25,9 @@ export interface ManualTestsPluginOptions { // The custom element that opts a page into the test chrome. The component script and its data // are injected only when the page source contains this marker. const MANUAL_HEADER_ELEMENT = 'ck-manual-header'; +const MANUAL_TEST_SUFFIX = '.manual.html'; +const MANUAL_TESTS_DIRECTORY = '/tests/manual/'; +const THEME_ENTRY_FILE_PATH = 'theme/index.css'; const HEAD_CLOSE_TAG = ''; const MANUAL_ENTRIES_VIRTUAL_ID = 'virtual:ckeditor5-manual-entries'; const MANUAL_THEME_ROOT = realpathSync( fileURLToPath( import.meta.resolve( '@ckeditor/ckeditor5-dev-manual-server/theme' ) ) ); @@ -45,6 +48,21 @@ export function manualTestsPlugin( options: ManualTestsPluginOptions ): Plugin { const getManualPageEntryForFile = ( filePath: string ): ManualPageEntry | undefined => { return getManualPages().get( toPublicSpecifier( relative( workspaceRoot, filePath ) ) ); }; + const getManualPageEntryForScriptSpecifier = ( scriptSpecifier: string ): ManualPageEntry | undefined => { + const htmlSpecifier = scriptSpecifier.replace( /\.(?:js|ts)$/, MANUAL_TEST_SUFFIX ); + + return htmlSpecifier == scriptSpecifier ? undefined : getManualPages().get( htmlSpecifier ); + }; + const packageThemeEntryCache = new Map(); + const hasPackageThemeEntry = ( packageRootSpecifier: string ): boolean => { + if ( !packageThemeEntryCache.has( packageRootSpecifier ) ) { + const themeEntryFilePath = resolve( workspaceRoot, stripLeadingSlash( packageRootSpecifier ), THEME_ENTRY_FILE_PATH ); + + packageThemeEntryCache.set( packageRootSpecifier, existsSync( themeEntryFilePath ) ); + } + + return packageThemeEntryCache.get( packageRootSpecifier )!; + }; const getManualCatalogBuildInputFilePath = () => resolve( workspaceRoot, 'index.html' ); const getManualCatalogPublicPath = () => toPublicFilePath( getManualCatalogBuildInputFilePath(), workspaceRoot ); const getManualCatalogScriptPublicPath = () => toPublicFilePath( MANUAL_CATALOG_SCRIPT_FILE_PATH, workspaceRoot ); @@ -80,6 +98,7 @@ export function manualTestsPlugin( options: ManualTestsPluginOptions ): Plugin { base = config.base || '/'; manualPagesCache.invalidate(); + packageThemeEntryCache.clear(); config.build.rolldownOptions.input = getManualBuildInputs(); }, @@ -120,6 +139,46 @@ export function manualTestsPlugin( options: ManualTestsPluginOptions ): Plugin { return null; }, + transform: { + order: 'pre', + + // Loads the package theme entry stylesheet (`theme/index.css`) in manual tests. + // Package stylesheets are imported by the package entry module (`src/index.ts`), not by + // individual source modules, and manual tests import source modules directly - without + // this they would render without the package's own styles. Stylesheets of other packages + // still arrive transitively through their package entry imports. Only the entry script of + // a discovered manual page is transformed; helper modules receive the styles through the + // entry that imports them. The import is appended after the module code: import + // declarations evaluate in source order, so the package styles load after the dependency + // styles pulled by the module's own imports, matching the cascade of the built bundles + // (where the package entry imports its stylesheet last). Appending also keeps the + // original line numbers, so no source map is needed. + handler( code, id ) { + const scriptSpecifier = toPublicSpecifier( relative( workspaceRoot, id.split( '?' )[ 0 ]! ) ); + const entry = getManualPageEntryForScriptSpecifier( scriptSpecifier ); + + if ( !entry ) { + return; + } + + const packageRootSpecifier = entry.htmlFilePath.slice( 0, entry.htmlFilePath.indexOf( MANUAL_TESTS_DIRECTORY ) ); + + if ( !hasPackageThemeEntry( packageRootSpecifier ) ) { + return; + } + + const themeEntrySpecifier = posix.relative( + posix.dirname( scriptSpecifier ), + `${ packageRootSpecifier }/${ THEME_ENTRY_FILE_PATH }` + ); + + return { + code: `${ code }\nimport '${ themeEntrySpecifier }';\n`, + map: null + }; + } + }, + transformIndexHtml: { order: 'pre', diff --git a/packages/ckeditor5-dev-manual-server/tests/manual-test-plugin/plugin.test.ts b/packages/ckeditor5-dev-manual-server/tests/manual-test-plugin/plugin.test.ts index 59727b252..6175eedd1 100644 --- a/packages/ckeditor5-dev-manual-server/tests/manual-test-plugin/plugin.test.ts +++ b/packages/ckeditor5-dev-manual-server/tests/manual-test-plugin/plugin.test.ts @@ -33,6 +33,10 @@ type TransformResult = string | undefined | { html: string; tags: Array string | null; type ResolveIdHook = ( id: string ) => string | null; interface MemoryFile { @@ -244,6 +248,97 @@ describe( 'manualTestsPlugin()', () => { expect( result.tags[ 0 ]!.injectTo ).to.equal( 'head-prepend' ); } ); + test( 'appends the package theme entry import to manual test entry scripts', async () => { + await Promise.all( [ + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.manual.html' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.js', 'console.log( 1 );\n' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/theme/index.css' ) + ] ); + + const transform = createConfiguredTransformHook( { paths: [ 'packages/*' ] } ); + const scriptFilePath = join( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.js' ); + const result = transform.handler( 'console.log( 1 );\n', scriptFilePath ); + + expect( transform.order ).to.equal( 'pre' ); + expect( result!.code ).to.equal( 'console.log( 1 );\n\nimport \'../../theme/index.css\';\n' ); + expect( result!.map ).to.equal( null ); + } ); + + test( 'appends the theme entry import to nested and TypeScript manual test entry scripts', async () => { + await Promise.all( [ + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/nested/bar.manual.html' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/nested/bar.ts', 'console.log( 1 );\n' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/theme/index.css' ) + ] ); + + const transform = createConfiguredTransformHook( { paths: [ 'packages/*' ] } ); + const scriptFilePath = join( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/nested/bar.ts' ); + const result = transform.handler( 'console.log( 1 );\n', scriptFilePath ); + + expect( result!.code ).to.contain( 'import \'../../../theme/index.css\';' ); + } ); + + test( 'ignores the query string when matching transformed manual test scripts', async () => { + await Promise.all( [ + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.manual.html' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.js', 'console.log( 1 );\n' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/theme/index.css' ) + ] ); + + const transform = createConfiguredTransformHook( { paths: [ 'packages/*' ] } ); + const scriptFilePath = join( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.js' ); + const result = transform.handler( 'console.log( 1 );\n', `${ scriptFilePath }?v=123` ); + + expect( result!.code ).to.contain( 'import \'../../theme/index.css\';' ); + } ); + + test( 'does not transform helper modules without a sibling manual page', async () => { + await Promise.all( [ + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.manual.html' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/_utils/helper.js', 'console.log( 1 );\n' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/theme/index.css' ) + ] ); + + const transform = createConfiguredTransformHook( { paths: [ 'packages/*' ] } ); + const scriptFilePath = join( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/_utils/helper.js' ); + + expect( transform.handler( 'console.log( 1 );\n', scriptFilePath ) ).to.equal( undefined ); + } ); + + test( 'does not transform entry scripts of packages without a theme entry stylesheet', async () => { + await Promise.all( [ + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.manual.html' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.js', 'console.log( 1 );\n' ) + ] ); + + const transform = createConfiguredTransformHook( { paths: [ 'packages/*' ] } ); + const scriptFilePath = join( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.js' ); + + expect( transform.handler( 'console.log( 1 );\n', scriptFilePath ) ).to.equal( undefined ); + } ); + + test( 'does not transform manual test scripts excluded from the run', async () => { + await Promise.all( [ + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.manual.html' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.js', 'console.log( 1 );\n' ), + createFile( workspaceRoot, 'packages/ckeditor5-foo/theme/index.css' ) + ] ); + + const transform = createConfiguredTransformHook( { paths: [ 'packages/*' ], include: [ 'bar' ] } ); + const scriptFilePath = join( workspaceRoot, 'packages/ckeditor5-foo/tests/manual/foo.js' ); + + expect( transform.handler( 'console.log( 1 );\n', scriptFilePath ) ).to.equal( undefined ); + } ); + + function createConfiguredTransformHook( options: ManualTestsPluginOptions ): TransformHook { + const plugin = manualTestsPlugin( options ); + const config = ( plugin.config as ConfigHook )(); + + ( plugin.configResolved as ConfigResolvedHook )( { root: workspaceRoot, base: './', build: config.build } ); + + return plugin.transform as unknown as TransformHook; + } + test( 'uses the Vite root instead of the current working directory for page entries', async () => { const currentWorkingDirectory = await createTemporaryDirectory( 'ckeditor5-manual-test-plugin-cwd-' ); @@ -588,4 +683,3 @@ function createMemoryFiles( entries: Record ): MemoryFilesLike { get: ( filePath: string ) => files.get( filePath ) }; } -