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 packages/typo3-docs-theme/assets/js/search-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@

if (manualPath) {
const searchScopeSelectList = document.getElementById(SELECTOR_SEARCH_SCOPE_SELECT_LIST);

// The scope select lives in the theme's page header. A project that
// overrides that template can match the manual URL pattern without
// rendering it, and the add() below would then dereference null and
// abort the remaining page scripts.
if (!searchScopeSelectList) {
return;
}

const newOption = document.createElement('option');
newOption.value = manualPath;
newOption.text = 'Search current';
Expand Down
2 changes: 1 addition & 1 deletion packages/typo3-docs-theme/resources/public/js/theme.min.js

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions packages/typo3-docs-theme/tests/js/search-form.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Regression test for the search scope option (search-form.js).
*
* The "Search current" option is added when the URL matches a manual path, but
* the select it is added to was looked up without a null check. A page header
* that does not render #searchscope — possible since templates became
* overridable — made the add() call dereference null and throw, which aborts
* the remaining page scripts.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

// Importing the script registers its load handler once.
import '../../assets/js/search-form.js';

const MANUAL_PATH = '/m/typo3/tutorial-getting-started/12.4/en-us/';

function visitManualPage() {
window.history.pushState({}, '', MANUAL_PATH + 'Concepts/Index.html');
}

// Errors thrown inside an event listener do not propagate to the dispatchEvent
// caller — the DOM reports them instead. Listening for them is what makes the
// guard actually testable.
function captureListenerErrors() {
const errors = [];
const onError = (event) => {
event.preventDefault();
errors.push(event.error ?? event.message);
};
window.addEventListener('error', onError);
return {
errors,
stop: () => window.removeEventListener('error', onError),
};
}

describe('search scope option', () => {
let capture;

beforeEach(() => {
document.body.replaceChildren();
capture = captureListenerErrors();
});

afterEach(() => {
capture.stop();
});

it('adds a "Search current" option scoped to the manual', () => {
const select = document.createElement('select');
select.id = 'searchscope';
document.body.appendChild(select);
visitManualPage();

window.dispatchEvent(new Event('load'));

expect(Array.from(select.options).map(option => option.value)).toContain(MANUAL_PATH);
expect(capture.errors).toEqual([]);
});

it('does not throw when the page header has no scope select', () => {
// URL matches a manual path, but #searchscope is absent.
visitManualPage();

window.dispatchEvent(new Event('load'));

expect(capture.errors).toEqual([]);
});

it('adds nothing outside a manual path', () => {
const select = document.createElement('select');
select.id = 'searchscope';
document.body.appendChild(select);
window.history.pushState({}, '', '/search/search?q=example');

window.dispatchEvent(new Event('load'));

expect(select.options).toHaveLength(0);
expect(capture.errors).toEqual([]);
});
});
18 changes: 17 additions & 1 deletion packages/typo3-docs-theme/tests/js/versions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,22 @@ describe('version switcher', () => {
// versionSelect.getAttribute() call threw "Cannot read properties of null".
document.body.replaceChildren();

expect(() => document.dispatchEvent(new Event('DOMContentLoaded'))).not.toThrow();
// Errors thrown inside an event listener do not propagate to the
// dispatchEvent caller — the DOM reports them instead — so asserting on
// dispatchEvent not throwing would pass even without the guard.
const errors = [];
const onError = (event) => {
event.preventDefault();
errors.push(event.error ?? event.message);
};
window.addEventListener('error', onError);

try {
document.dispatchEvent(new Event('DOMContentLoaded'));
} finally {
window.removeEventListener('error', onError);
}

expect(errors).toEqual([]);
});
});