Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/20260723103146_ci_4311.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
type: Feature
scope:
- ckeditor5-dev-manual-server
---

Improved the manual test runner UI to make tests faster and easier to find.

It is now possible to save frequently used tests as favorites. Search results show the best matching packages first. Matching package and test names are highlighted. The clear button now looks the same across browsers.
2 changes: 1 addition & 1 deletion packages/ckeditor5-dev-build-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"cssnano-preset-lite": "^4.0.4",
"es-toolkit": "^1.45.1",
"glob": "^13.0.6",
"lightningcss": "^1.32.0",
"lightningcss": "^1.33.0",
"pofile": "^1.1.4",
"purgecss": "^8.0.0",
"rolldown": "^1.1.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/

import { describe, expect, test, vi } from 'vitest';
import {
filterFavoriteEntries,
getFavoriteId,
loadFavoriteIds,
saveFavoriteIds,
toggleFavoriteId
} from '../../theme/catalog-favorites.js';

const FAVORITES_STORAGE_KEY = 'ckeditor5-manual-test-favorites';

describe( 'getFavoriteId()', () => {
test( 'combines the package name and nested slug', () => {
expect( getFavoriteId( {
packageName: 'ckeditor5-foo',
slug: 'nested/advanced'
} ) ).to.equal( 'ckeditor5-foo/nested/advanced' );
} );
} );

describe( 'filterFavoriteEntries()', () => {
test( 'returns matching entry objects in their original order and ignores stale identifiers', () => {
const entries = [
{ packageName: 'ckeditor5-foo', slug: 'basic', href: '/foo/basic' },
{ packageName: 'ckeditor5-bar', slug: 'basic', href: '/bar/basic' },
{ packageName: 'ckeditor5-foo', slug: 'nested/advanced', href: '/foo/advanced' }
];
const favoriteIds = new Set( [
'ckeditor5-foo/basic',
'ckeditor5-foo/nested/advanced',
'ckeditor5-missing/basic'
] );
const result = filterFavoriteEntries( entries, favoriteIds );

expect( result ).to.deep.equal( [ entries[ 0 ], entries[ 2 ] ] );
expect( result[ 0 ] ).to.equal( entries[ 0 ] );
expect( result[ 1 ] ).to.equal( entries[ 2 ] );
} );
} );

describe( 'toggleFavoriteId()', () => {
test( 'adds an identifier that is not a favorite', () => {
const favoriteIds = new Set( [ 'ckeditor5-foo/basic' ] );

toggleFavoriteId( favoriteIds, 'ckeditor5-bar/basic' );

expect( [ ...favoriteIds ] ).to.deep.equal( [ 'ckeditor5-foo/basic', 'ckeditor5-bar/basic' ] );
} );

test( 'removes an identifier that is already a favorite', () => {
const favoriteIds = new Set( [ 'ckeditor5-foo/basic', 'ckeditor5-bar/basic' ] );

toggleFavoriteId( favoriteIds, 'ckeditor5-foo/basic' );

expect( [ ...favoriteIds ] ).to.deep.equal( [ 'ckeditor5-bar/basic' ] );
} );
} );

describe( 'loadFavoriteIds()', () => {
test( 'loads favorite identifiers from local storage', () => {
const getItem = vi.fn().mockReturnValue( '["foo/bar","baz/qux","foo/bar"]' );

vi.stubGlobal( 'localStorage', { getItem } );

expect( [ ...loadFavoriteIds() ] ).to.deep.equal( [ 'foo/bar', 'baz/qux' ] );
expect( getItem ).toHaveBeenCalledWith( FAVORITES_STORAGE_KEY );
} );

test( 'returns an empty set when no favorites are stored', () => {
vi.stubGlobal( 'localStorage', { getItem: vi.fn().mockReturnValue( null ) } );

expect( [ ...loadFavoriteIds() ] ).to.deep.equal( [] );
} );

test.each( [
'{broken',
'{}',
'["foo/bar",42]'
] )( 'returns an empty set for invalid stored data: %s', storedValue => {
vi.stubGlobal( 'localStorage', { getItem: vi.fn().mockReturnValue( storedValue ) } );

expect( [ ...loadFavoriteIds() ] ).to.deep.equal( [] );
} );

test( 'returns an empty set when local storage is unavailable', () => {
vi.stubGlobal( 'localStorage', {
getItem: vi.fn( () => {
throw new Error( 'Storage is unavailable.' );
} )
} );

expect( [ ...loadFavoriteIds() ] ).to.deep.equal( [] );
} );
} );

describe( 'saveFavoriteIds()', () => {
test( 'persists favorite identifiers in local storage', () => {
const setItem = vi.fn();

vi.stubGlobal( 'localStorage', { setItem } );
saveFavoriteIds( new Set( [ 'foo/bar', 'baz/qux' ] ) );

expect( setItem ).toHaveBeenCalledWith( FAVORITES_STORAGE_KEY, '["foo/bar","baz/qux"]' );
} );

test( 'ignores local storage write failures', () => {
vi.stubGlobal( 'localStorage', {
setItem: vi.fn( () => {
throw new Error( 'Storage is unavailable.' );
} )
} );

expect( () => saveFavoriteIds( new Set( [ 'foo/bar' ] ) ) ).not.toThrow();
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/

import { describe, expect, test } from 'vitest';
import { filterEntries, findMatchOffsets } from '../../theme/catalog-search.js';

describe( 'filterEntries()', () => {
const entries = [
{
href: '/packages/ckeditor5-foo/tests/manual/basic.manual.html',
packageName: 'ckeditor5-foo',
slug: 'basic'
},
{
href: '/packages/ckeditor5-foo/tests/manual/nested/advanced.manual.html',
packageName: 'ckeditor5-foo',
slug: 'nested/advanced'
},
{
href: '/packages/ckeditor5-bar/tests/manual/basic.manual.html',
packageName: 'ckeditor5-bar',
slug: 'basic'
}
];

test( 'returns all entries for an empty query', () => {
expect( filterEntries( entries, '' ) ).to.equal( entries );
} );

test( 'returns all tests belonging to a matching package', () => {
expect( filterEntries( entries, 'foo' ) ).to.deep.equal( entries.slice( 0, 2 ) );
} );

test( 'returns tests with a matching slug, including nested slugs', () => {
expect( filterEntries( entries, 'advanced' ) ).to.deep.equal( [ entries[ 1 ] ] );
} );

test( 'returns no entries when the query does not match', () => {
expect( filterEntries( entries, 'missing' ) ).to.deep.equal( [] );
} );

test( 'preserves original entry objects and their additional fields', () => {
const [ result ] = filterEntries( entries, 'advanced' );

expect( result ).to.equal( entries[ 1 ] );
expect( result!.href ).to.equal( '/packages/ckeditor5-foo/tests/manual/nested/advanced.manual.html' );
} );

test( 'ranks exact, prefix and partial package matches before slug-only matches', () => {
const entries = [
{ packageName: 'ckeditor5-bar', slug: 'foo-test' },
{ packageName: 'ckeditor5-extra-foo', slug: 'unrelated' },
{ packageName: 'ckeditor5-foo-extra', slug: 'unrelated' },
{ packageName: 'ckeditor5-foo', slug: 'unrelated' }
];

expect( filterEntries( entries, 'foo' ) ).to.deep.equal( [ entries[ 3 ], entries[ 2 ], entries[ 1 ], entries[ 0 ] ] );
} );

test( 'ranks slug-only package matches by the number of matching tests', () => {
const entries = [
{ packageName: 'ckeditor5-bar', slug: 'foo-test' },
{ packageName: 'ckeditor5-baz', slug: 'foo-basic' },
{ packageName: 'ckeditor5-baz', slug: 'nested/foo-advanced' }
];

expect( filterEntries( entries, 'foo' ) ).to.deep.equal( [ entries[ 1 ], entries[ 2 ], entries[ 0 ] ] );
} );

test( 'ranks exact package names without the ckeditor5 prefix', () => {
const entries = [
{ packageName: 'bar', slug: 'foo-test' },
{ packageName: 'foo', slug: 'basic' }
];

expect( filterEntries( entries, 'foo' ) ).to.deep.equal( [ entries[ 1 ], entries[ 0 ] ] );
} );

test( 'preserves package and test order when ranking signals are equal', () => {
const entries = [
{ packageName: 'ckeditor5-foo', slug: 'nested/zeta' },
{ packageName: 'ckeditor5-foo', slug: 'nested/alpha' },
{ packageName: 'ckeditor5-bar', slug: 'nested/zeta' },
{ packageName: 'ckeditor5-bar', slug: 'nested/alpha' }
];

expect( filterEntries( entries, 'nested' ) ).to.deep.equal( entries );
} );
} );

describe( 'findMatchOffsets()', () => {
test( 'finds matches at the beginning, middle and end of text', () => {
expect( findMatchOffsets( 'foo-prefix-foo-suffix-foo', 'foo' ) ).to.deep.equal( [
{ start: 0, end: 3 },
{ start: 11, end: 14 },
{ start: 22, end: 25 }
] );
} );

test( 'finds matches case-insensitively while preserving original offsets', () => {
expect( findMatchOffsets( 'CKEditor5-ckeditor5', 'ckeditor5' ) ).to.deep.equal( [
{ start: 0, end: 9 },
{ start: 10, end: 19 }
] );
} );

test( 'treats regular expression characters literally', () => {
expect( findMatchOffsets( 'foo.[bar]-foo.[bar]', '.[bar]' ) ).to.deep.equal( [
{ start: 3, end: 9 },
{ start: 13, end: 19 }
] );
} );

test( 'returns no matches for an empty or absent query', () => {
expect( findMatchOffsets( 'foo', '' ) ).to.deep.equal( [] );
expect( findMatchOffsets( 'foo', 'bar' ) ).to.deep.equal( [] );
} );

test( 'finds adjacent matches', () => {
expect( findMatchOffsets( 'foofoo', 'foo' ) ).to.deep.equal( [
{ start: 0, end: 3 },
{ start: 3, end: 6 }
] );
} );

test( 'returns non-overlapping matches', () => {
expect( findMatchOffsets( 'aaaa', 'aa' ) ).to.deep.equal( [
{ start: 0, end: 2 },
{ start: 2, end: 4 }
] );
} );
} );
70 changes: 70 additions & 0 deletions packages/ckeditor5-dev-manual-server/theme/catalog-favorites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/

interface FavoritableEntry {
packageName: string;
slug: string;
}

const FAVORITES_STORAGE_KEY = 'ckeditor5-manual-test-favorites';

/**
* Returns the stable identifier used to persist a manual test as a favorite.
*/
export function getFavoriteId( entry: FavoritableEntry ): string {
return `${ entry.packageName }/${ entry.slug }`;
}

/**
* Returns favorite entries in their original order.
*/
export function filterFavoriteEntries<T extends FavoritableEntry>( entries: Array<T>, favoriteIds: Set<string> ): Array<T> {
return entries.filter( entry => favoriteIds.has( getFavoriteId( entry ) ) );
}

/**
* Toggles an identifier in the favorite set.
*/
export function toggleFavoriteId( favoriteIds: Set<string>, favoriteId: string ): void {
if ( favoriteIds.has( favoriteId ) ) {
favoriteIds.delete( favoriteId );
} else {
favoriteIds.add( favoriteId );
}
}

/**
* Loads favorite entry identifiers from local storage.
*/
export function loadFavoriteIds(): Set<string> {
try {
const storedValue = localStorage.getItem( FAVORITES_STORAGE_KEY );

if ( !storedValue ) {
return new Set();
}

const parsedValue: unknown = JSON.parse( storedValue );

if ( !Array.isArray( parsedValue ) || parsedValue.some( value => typeof value != 'string' ) ) {
return new Set();
}

return new Set( parsedValue );
} catch {
return new Set();
}
}

/**
* Persists favorite entry identifiers in local storage.
*/
export function saveFavoriteIds( favoriteIds: Set<string> ): void {
try {
localStorage.setItem( FAVORITES_STORAGE_KEY, JSON.stringify( [ ...favoriteIds ] ) );
} catch {
// Favorites are optional, so storage failures must not block the catalog.
}
}
Loading