Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changelog/20260710160000_ci_4265.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = '</head>';
const MANUAL_ENTRIES_VIRTUAL_ID = 'virtual:ckeditor5-manual-entries';
const MANUAL_THEME_ROOT = realpathSync( fileURLToPath( import.meta.resolve( '@ckeditor/ckeditor5-dev-manual-server/theme' ) ) );
Expand All @@ -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<string, boolean>();
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 );
Expand Down Expand Up @@ -80,6 +98,7 @@ export function manualTestsPlugin( options: ManualTestsPluginOptions ): Plugin {
base = config.base || '/';

manualPagesCache.invalidate();
packageThemeEntryCache.clear();

config.build.rolldownOptions.input = getManualBuildInputs();
},
Expand Down Expand Up @@ -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',

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ type TransformResult = string | undefined | { html: string; tags: Array<HtmlTagD
type TransformIndexHtmlHook = {
handler( html: string, context: { filename: string } ): TransformResult;
};
type TransformHook = {
order: string;
handler( code: string, id: string ): { code: string; map: null } | undefined;
};
type LoadHook = ( id: string ) => string | null;
type ResolveIdHook = ( id: string ) => string | null;
interface MemoryFile {
Expand Down Expand Up @@ -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-' );

Expand Down Expand Up @@ -588,4 +683,3 @@ function createMemoryFiles( entries: Record<string, string> ): MemoryFilesLike {
get: ( filePath: string ) => files.get( filePath )
};
}