mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-20 02:46:14 +02:00
test: run frontend unit tests in browsers (#38860)
Run them in headless [vitest browser mode](https://vitest.dev/guide/browser/) in chromium and firefox. Similar UX than current tests, it's about 5 times as slow (goes from 1s to 5s on my machine), but definitely worth it as it removes all happy-dom problems. --------- Signed-off-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import {execPseudoSelectorCommands, handleFetchActionErrorFields, handleFetchActionSuccessJson} from './common-fetch-action.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {normalizeTestHtml} from '../utils/testhelper.ts';
|
||||
import {captureNavigations, normalizeTestHtml} from '../utils/testhelper.ts';
|
||||
|
||||
test('execPseudoSelectorCommands', () => {
|
||||
window.document.body.innerHTML = `
|
||||
@@ -41,23 +41,11 @@ test('execPseudoSelectorCommands', () => {
|
||||
});
|
||||
|
||||
test('handleFetchActionSuccessJson', async () => {
|
||||
const spyAssign = vi.spyOn(window.location, 'assign').mockImplementation(() => {});
|
||||
const spyReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
|
||||
|
||||
const navigations = captureNavigations();
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: '/'});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(1);
|
||||
expect(spyReload).toHaveBeenCalledTimes(0);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: ''});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
expect(navigations.map((n) => n.type)).toEqual(['push', 'reload', 'reload']);
|
||||
});
|
||||
|
||||
test('handleFetchActionErrorFields', () => {
|
||||
|
||||
@@ -3,13 +3,8 @@ import {POST} from '../modules/fetch.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import type {SortableEvent} from 'sortablejs';
|
||||
|
||||
vi.mock('../modules/fetch.ts', () => ({
|
||||
POST: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../modules/sortable.ts', () => ({
|
||||
createSortable: vi.fn(),
|
||||
}));
|
||||
vi.mock('../modules/fetch.ts', () => ({POST: vi.fn()}));
|
||||
vi.mock('../modules/sortable.ts', () => ({createSortable: vi.fn()}));
|
||||
|
||||
const branchesHTML = `
|
||||
<div id="protected-branches-list" data-update-priority-url="some/repo/branches/priority">
|
||||
@@ -26,29 +21,29 @@ const branchesHTML = `
|
||||
`;
|
||||
|
||||
describe('Repository Branch Settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(createSortable).mockClear();
|
||||
vi.mocked(POST).mockClear();
|
||||
});
|
||||
|
||||
test('should initialize sortable for protected branches list', () => {
|
||||
document.body.innerHTML = branchesHTML;
|
||||
const callsBefore = vi.mocked(createSortable).mock.calls.length;
|
||||
initRepoSettingsBranchesDrag();
|
||||
const newCalls = vi.mocked(createSortable).mock.calls.slice(callsBefore);
|
||||
expect(newCalls).toHaveLength(1);
|
||||
expect(newCalls[0][0]).toBe(document.querySelector('#protected-branches-list'));
|
||||
expect(newCalls[0][1]).toMatchObject({handle: '.drag-handle', animation: 150});
|
||||
expect(createSortable).toHaveBeenCalledTimes(1);
|
||||
expect(createSortable).toHaveBeenCalledWith(document.querySelector('#protected-branches-list'), expect.objectContaining({handle: '.drag-handle', animation: 150}));
|
||||
});
|
||||
|
||||
test('should not initialize if protected branches list is not present', () => {
|
||||
document.querySelector('#protected-branches-list')?.remove();
|
||||
const callsBefore = vi.mocked(createSortable).mock.calls.length;
|
||||
document.body.replaceChildren();
|
||||
initRepoSettingsBranchesDrag();
|
||||
expect(vi.mocked(createSortable).mock.calls.length).toBe(callsBefore);
|
||||
expect(createSortable).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('should post new order after sorting', () => {
|
||||
document.body.innerHTML = branchesHTML;
|
||||
vi.mocked(POST).mockResolvedValue({ok: true} as Response);
|
||||
const callsBefore = vi.mocked(createSortable).mock.calls.length;
|
||||
initRepoSettingsBranchesDrag();
|
||||
const onEnd = vi.mocked(createSortable).mock.calls[callsBefore][1]!.onEnd!;
|
||||
const onEnd = vi.mocked(createSortable).mock.calls[0][1]!.onEnd!;
|
||||
onEnd(new Event('SortableEvent') as SortableEvent);
|
||||
expect(POST).toHaveBeenCalledWith(
|
||||
'some/repo/branches/priority',
|
||||
|
||||
@@ -1,47 +1,35 @@
|
||||
import {navigateToIframeLink} from './render-iframe.ts';
|
||||
import {captureNavigations} from '../utils/testhelper.ts';
|
||||
|
||||
describe('navigateToIframeLink', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const assignSpy = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined);
|
||||
|
||||
test('safe links', () => {
|
||||
const navigations = captureNavigations();
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
navigateToIframeLink('http://example.com', '_blank');
|
||||
expect(openSpy).toHaveBeenCalledWith('http://example.com/', '_blank', 'noopener,noreferrer');
|
||||
vi.clearAllMocks();
|
||||
|
||||
navigateToIframeLink('https://example.com', '_self');
|
||||
expect(assignSpy).toHaveBeenCalledWith('https://example.com/');
|
||||
vi.clearAllMocks();
|
||||
|
||||
expect(navigations.at(-1)!.url).toEqual('https://example.com/');
|
||||
navigateToIframeLink('https://example.com', null);
|
||||
expect(assignSpy).toHaveBeenCalledWith('https://example.com/');
|
||||
vi.clearAllMocks();
|
||||
|
||||
expect(navigations.at(-1)!.url).toEqual('https://example.com/');
|
||||
navigateToIframeLink('/path', '');
|
||||
expect(assignSpy).toHaveBeenCalledWith('http://localhost:3000/path');
|
||||
vi.clearAllMocks();
|
||||
|
||||
expect(navigations.at(-1)!.url).toEqual(`${window.location.origin}/path`);
|
||||
// input can be any type & any value, keep the same behavior as `window.location.href = 0`
|
||||
navigateToIframeLink(0, {});
|
||||
expect(assignSpy).toHaveBeenCalledWith('http://localhost:3000/0');
|
||||
vi.clearAllMocks();
|
||||
expect(navigations.at(-1)!.url).toEqual(`${window.location.origin}/0`);
|
||||
expect(navigations).toHaveLength(4);
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('unsafe links', () => {
|
||||
const navigations = captureNavigations();
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
navigateToIframeLink('javascript:void(0);', '_blank');
|
||||
expect(openSpy).toHaveBeenCalledTimes(0);
|
||||
expect(assignSpy).toHaveBeenCalledTimes(0);
|
||||
expect(window.location.href).toBe('http://localhost:3000/');
|
||||
vi.clearAllMocks();
|
||||
|
||||
navigateToIframeLink('data:image/svg+xml;utf8,<svg></svg>', '');
|
||||
expect(openSpy).toHaveBeenCalledTimes(0);
|
||||
expect(assignSpy).toHaveBeenCalledTimes(0);
|
||||
expect(window.location.href).toBe('http://localhost:3000/');
|
||||
expect(navigations).toEqual([]);
|
||||
openSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,18 +5,19 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
test('isGiteaError', () => {
|
||||
const {origin} = window.location;
|
||||
expect(isGiteaError('', '')).toBe(true);
|
||||
expect(isGiteaError('moz-extension://abc/content.js', '')).toBe(false);
|
||||
expect(isGiteaError('safari-extension://abc/content.js', '')).toBe(false);
|
||||
expect(isGiteaError('safari-web-extension://abc/content.js', '')).toBe(false);
|
||||
expect(isGiteaError('chrome-extension://abc/content.js', '')).toBe(false);
|
||||
expect(isGiteaError('https://other-site.com/script.js', '')).toBe(false);
|
||||
expect(isGiteaError('http://localhost:3000/some/page', '')).toBe(true);
|
||||
expect(isGiteaError('http://localhost:3000/assets/js/index.abc123.js', '')).toBe(true);
|
||||
expect(isGiteaError(`${origin}/some/page`, '')).toBe(true);
|
||||
expect(isGiteaError(`${origin}/assets/js/index.abc123.js`, '')).toBe(true);
|
||||
expect(isGiteaError('', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
|
||||
expect(isGiteaError('', `Error\n at https://other-site.com/script.js:1:1`)).toBe(false);
|
||||
expect(isGiteaError('', `Error\n at http://localhost:3000/assets/js/index.abc123.js:1:1`)).toBe(true);
|
||||
expect(isGiteaError('http://localhost:3000/assets/js/index.js', `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
|
||||
expect(isGiteaError('', `Error\n at ${origin}/assets/js/index.abc123.js:1:1`)).toBe(true);
|
||||
expect(isGiteaError(`${origin}/assets/js/index.js`, `Error\n at chrome-extension://abc/content.js:1:1`)).toBe(false);
|
||||
});
|
||||
|
||||
test('showGlobalErrorMessage', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ test('renderAnsi', () => {
|
||||
|
||||
// treat "\033[0K" and "\033[0J" (Erase display/line) as "\r", then it will be covered to "\n" finally.
|
||||
expect(renderAnsi('a\x1b[Kb\x1b[2Jc')).toEqual('a\nb\nc');
|
||||
expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`<span style="background-color: #870000;">a</span><span style="background-color: #afffff;">b</span>`);
|
||||
expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`<span style="background-color: rgb(135, 0, 0);">a</span><span style="background-color: rgb(175, 255, 255);">b</span>`);
|
||||
|
||||
// URLs in ANSI output become clickable links
|
||||
const link = (url: string) => `<a href="${url}" target="_blank">${url}</a>`;
|
||||
@@ -39,7 +39,7 @@ test('renderAnsi', () => {
|
||||
expect(renderAnsi('\x1b[4;58;5;9mx')).toEqual('<span class="ansi-underline" style="text-decoration-color: var(--color-ansi-bright-red);">x</span>');
|
||||
|
||||
// a color as ":" sub-parameters, with and without a color space id, not consuming the codes after
|
||||
expect(renderAnsi('\x1b[38:2::255:0:0ma\x1b[48:2:0:0:255mb')).toEqual('<span style="color: #ff0000;">a</span><span style="color: #ff0000; background-color: #0000ff;">b</span>');
|
||||
expect(renderAnsi('\x1b[38:2::255:0:0ma\x1b[48:2:0:0:255mb')).toEqual('<span style="color: rgb(255, 0, 0);">a</span><span style="color: rgb(255, 0, 0); background-color: rgb(0, 0, 255);">b</span>');
|
||||
expect(renderAnsi('\x1b[1;38:5:9;4mx')).toEqual('<span class="ansi-bold ansi-underline ansi-bright-red-fg">x</span>');
|
||||
// a private CSI carries no style, even ending in "m", and does not split the run around it
|
||||
expect(renderAnsi('\x1b[31mred\x1b[>4;2m!')).toEqual('<span class="ansi-red-fg">red!</span>');
|
||||
|
||||
@@ -26,15 +26,16 @@ test('createElementFromAttrs', () => {
|
||||
});
|
||||
|
||||
test('querySingleVisibleElem', () => {
|
||||
let el = createElementFromHTML('<div></div>');
|
||||
const el = document.createElement('div');
|
||||
document.body.append(el); // layout, and thus visibility, is only computed in the document
|
||||
expect(querySingleVisibleElem(el, 'span')).toBeNull();
|
||||
el = createElementFromHTML('<div><span>foo</span></div>');
|
||||
el.innerHTML = '<span>foo</span>';
|
||||
expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('foo');
|
||||
el = createElementFromHTML('<div><span style="display: none;">foo</span><span>bar</span></div>');
|
||||
el.innerHTML = '<span style="display: none;">foo</span><span>bar</span>';
|
||||
expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('bar');
|
||||
el = createElementFromHTML('<div><span class="some-class tw-hidden">foo</span><span>bar</span></div>');
|
||||
el.innerHTML = '<span class="some-class tw-hidden">foo</span><span>bar</span>';
|
||||
expect(querySingleVisibleElem(el, 'span')!.textContent).toEqual('bar');
|
||||
el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>');
|
||||
el.innerHTML = '<span>foo</span><span>bar</span>';
|
||||
expect(() => querySingleVisibleElem(el, 'span')).toThrow('Expected exactly one visible element');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {debounce} from './func.ts';
|
||||
import type {Promisable} from '../types.ts';
|
||||
import type $ from 'jquery';
|
||||
import {isInFrontendUnitTest} from './testhelper.ts';
|
||||
|
||||
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
|
||||
type ElementArg = Element | string | ArrayLikeIterable<Element> | ReturnType<typeof $>;
|
||||
@@ -73,11 +72,6 @@ export function queryElemSiblings<T extends Element>(el: Element, selector = '*'
|
||||
|
||||
/** it works like jQuery.children: only the direct children are selected */
|
||||
export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
|
||||
if (isInFrontendUnitTest()) {
|
||||
// https://github.com/capricorn86/happy-dom/issues/1620 : ":scope" doesn't work
|
||||
const selected = Array.from<T>(parent.children as any).filter((child) => child.matches(selector));
|
||||
return applyElemsCallback<T>(selected, fn);
|
||||
}
|
||||
return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn);
|
||||
}
|
||||
|
||||
@@ -261,8 +255,7 @@ export function isElemVisible(el: HTMLElement): boolean {
|
||||
// Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
|
||||
// This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem"
|
||||
if (!el) return false;
|
||||
// checking el.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout
|
||||
return Boolean(!el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none');
|
||||
return Boolean(!el.classList.contains('tw-hidden') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
|
||||
}
|
||||
|
||||
export function createElementFromHTML<T extends Element>(htmlString: string): T {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import globTestData from './glob.test.txt';
|
||||
import {globCompile} from './glob.ts';
|
||||
|
||||
async function loadGlobTestData(): Promise<{caseNames: string[], caseDataMap: Record<string, string>}> {
|
||||
const fileContent = await readFile(path.join(import.meta.dirname, 'glob.test.txt'), 'utf8');
|
||||
const fileLines = fileContent.split('\n');
|
||||
function loadGlobTestData(): {caseNames: string[], caseDataMap: Record<string, string>} {
|
||||
const fileLines = globTestData.split('\n');
|
||||
const caseDataMap: Record<string, string> = {};
|
||||
const caseNameMap: Record<string, boolean> = {};
|
||||
for (let line of fileLines) {
|
||||
@@ -103,8 +101,8 @@ function loadGlobGolangCases() {
|
||||
];
|
||||
}
|
||||
|
||||
test('GlobCompiler', async () => {
|
||||
const {caseNames, caseDataMap} = await loadGlobTestData();
|
||||
test('GlobCompiler', () => {
|
||||
const {caseNames, caseDataMap} = loadGlobTestData();
|
||||
expect(caseNames.length).toBe(10); // should have 10 test cases
|
||||
for (const caseName of caseNames) {
|
||||
const pattern = caseDataMap[`pattern_${caseName}`];
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {matchEmoji, matchMention} from './match.ts';
|
||||
|
||||
vi.mock('../modules/fetch.ts', () => ({
|
||||
GET: vi.fn(),
|
||||
}));
|
||||
vi.mock('../modules/fetch.ts', () => ({GET: vi.fn()}));
|
||||
|
||||
const testMentions = [
|
||||
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
// there could be different "testing" concepts, for example: backend's "setting.IsInTesting"
|
||||
// even if backend is in testing mode, frontend could be complied in production mode
|
||||
// so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files)
|
||||
export function isInFrontendUnitTest() {
|
||||
return import.meta.env.MODE === 'test';
|
||||
import {onTestFinished} from 'vitest';
|
||||
|
||||
/** Record and block navigations, as a real browser forbids stubbing "window.location" */
|
||||
export function captureNavigations() {
|
||||
const navigations: Array<{url: string, type: NavigationType}> = [];
|
||||
const onNavigate = (e: NavigateEvent) => {
|
||||
navigations.push({url: e.destination.url, type: e.navigationType});
|
||||
e.preventDefault();
|
||||
};
|
||||
window.navigation.addEventListener('navigate', onNavigate);
|
||||
onTestFinished(() => window.navigation.removeEventListener('navigate', onNavigate));
|
||||
return navigations;
|
||||
}
|
||||
|
||||
/** strip common indentation from a string and trim it */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './globals.ts';
|
||||
|
||||
window.config = {
|
||||
appUrl: 'http://localhost:3000/',
|
||||
appUrl: `${window.location.origin}/`,
|
||||
appSubUrl: '',
|
||||
assetUrlPrefix: '/assets',
|
||||
sharedWorkerUri: '',
|
||||
|
||||
Reference in New Issue
Block a user