test: add frontend tests (#3991)

This commit is contained in:
Sami Mazouz 2024-09-28 15:47:45 +01:00 committed by GitHub
parent c0d3d976fa
commit 257be2b9db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 4581 additions and 3167 deletions

View File

@ -74,7 +74,7 @@ on:
description: The node version to use for the workflow. description: The node version to use for the workflow.
type: number type: number
required: false required: false
default: 16 default: 20
js_package_manager: js_package_manager:
description: "Enable TypeScript?" description: "Enable TypeScript?"
@ -142,7 +142,7 @@ jobs:
working-directory: ${{ inputs.frontend_directory }} working-directory: ${{ inputs.frontend_directory }}
- name: JS Checks & Production Build - name: JS Checks & Production Build
uses: flarum/action-build@v3 uses: flarum/action-build@v4
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: ${{ inputs.build_script }} build_script: ${{ inputs.build_script }}

View File

@ -93,7 +93,7 @@ class ListTest extends TestCase
$data = json_decode($body, true); $data = json_decode($body, true);
$tagIds = array_map(function ($tag) { $tagIds = array_map(function ($tag) {
return $tag['id']; return (int) $tag['id'];
}, array_filter($data['included'], function ($item) { }, array_filter($data['included'], function ($item) {
return $item['type'] === 'tags'; return $item['type'] === 'tags';
})); }));

View File

@ -221,6 +221,13 @@ export default class ExportRegistry implements IExportRegistry, IChunkRegistry {
}); });
} }
public clear(): void {
this.moduleExports.clear();
this.onLoads.clear();
this.chunks.clear();
this.chunkModules.clear();
}
namespaceAndIdFromPath(path: string): [string, string] { namespaceAndIdFromPath(path: string): [string, string] {
// Either we get a path like `flarum/forum/components/LogInModal` or `ext:flarum/tags/forum/components/TagPage`. // Either we get a path like `flarum/forum/components/LogInModal` or `ext:flarum/tags/forum/components/TagPage`.
const matches = /^(?:ext:([^\/]+)\/(?:flarum-(?:ext-)?)?([^\/]+)|(flarum))(?:\/(.+))?$/.exec(path); const matches = /^(?:ext:([^\/]+)\/(?:flarum-(?:ext-)?)?([^\/]+)|(flarum))(?:\/(.+))?$/.exec(path);

View File

@ -36,7 +36,7 @@ export default class Avatar<CustomAttrs extends IAvatarAttrs = IAvatarAttrs> ext
} }
content = username.charAt(0).toUpperCase(); content = username.charAt(0).toUpperCase();
attrs.style = { '--avatar-bg': user.color() }; attrs.style = !process.env.testing && { '--avatar-bg': user.color() };
} }
return <span {...attrs}>{content}</span>; return <span {...attrs}>{content}</span>;

View File

@ -32,7 +32,11 @@ export default class Badge<CustomAttrs extends IBadgeAttrs = IBadgeAttrs> extend
const iconChild = iconName ? <Icon name={iconName} className="Badge-icon" /> : m.trust('&nbsp;'); const iconChild = iconName ? <Icon name={iconName} className="Badge-icon" /> : m.trust('&nbsp;');
const newStyle = { ...style, '--badge-bg': color }; const newStyle = { ...style };
if (!process.env.testing) {
newStyle['--badge-bg'] = color;
}
const badgeAttrs = { const badgeAttrs = {
...attrs, ...attrs,

View File

@ -1,13 +1,22 @@
import type Mithril from 'mithril'; import type Mithril from 'mithril';
import Component, { ComponentAttrs } from '../Component'; import Component, { ComponentAttrs } from '../Component';
import classList from '../utils/classList'; import classList from '../utils/classList';
import Icon from './Icon'; import Icon from './Icon';
export default class ColorPreviewInput extends Component { export interface IColorPreviewInputAttrs extends ComponentAttrs {
view(vnode: Mithril.Vnode<ComponentAttrs, this>) { value: string;
const { className, id, ...attrs } = this.attrs; id?: string;
type?: string;
onchange?: (event: { target: { value: string } }) => void;
}
export default class ColorPreviewInput<
CustomAttributes extends IColorPreviewInputAttrs = IColorPreviewInputAttrs
> extends Component<CustomAttributes> {
view(vnode: Mithril.Vnode<CustomAttributes, this>) {
const { className, id, ...otherAttrs } = this.attrs;
const attrs = otherAttrs as unknown as IColorPreviewInputAttrs;
attrs.type ||= 'text'; attrs.type ||= 'text';

View File

@ -1,5 +1,4 @@
import Component, { ComponentAttrs } from '../Component'; import Component, { ComponentAttrs } from '../Component';
import listItems from '../helpers/listItems';
import classList from '../utils/classList'; import classList from '../utils/classList';
import Mithril from 'mithril'; import Mithril from 'mithril';

View File

@ -74,14 +74,14 @@ const StackedFormControlType = 'stacked-text' as const;
* Valid options for the setting component builder to generate a Switch. * Valid options for the setting component builder to generate a Switch.
*/ */
export interface SwitchFieldComponentOptions extends CommonFieldOptions { export interface SwitchFieldComponentOptions extends CommonFieldOptions {
type: typeof BooleanSettingTypes[number]; type: (typeof BooleanSettingTypes)[number];
} }
/** /**
* Valid options for the setting component builder to generate a Select dropdown. * Valid options for the setting component builder to generate a Select dropdown.
*/ */
export interface SelectFieldComponentOptions extends CommonFieldOptions { export interface SelectFieldComponentOptions extends CommonFieldOptions {
type: typeof SelectSettingTypes[number] | typeof RadioSettingTypes[number]; type: (typeof SelectSettingTypes)[number] | (typeof RadioSettingTypes)[number];
/** /**
* Map of values to their labels * Map of values to their labels
*/ */
@ -101,7 +101,7 @@ export interface SelectFieldComponentOptions extends CommonFieldOptions {
* Valid options for the setting component builder to generate a Textarea. * Valid options for the setting component builder to generate a Textarea.
*/ */
export interface TextareaFieldComponentOptions extends CommonFieldOptions { export interface TextareaFieldComponentOptions extends CommonFieldOptions {
type: typeof TextareaSettingTypes[number]; type: (typeof TextareaSettingTypes)[number];
} }
/** /**
@ -246,7 +246,7 @@ export default class FormGroup<CustomAttrs extends IFormGroupAttrs = IFormGroupA
if ((TextareaSettingTypes as readonly string[]).includes(type)) { if ((TextareaSettingTypes as readonly string[]).includes(type)) {
settingElement = <textarea id={inputId} aria-describedby={helpTextId} bidi={stream} {...attrs} />; settingElement = <textarea id={inputId} aria-describedby={helpTextId} bidi={stream} {...attrs} />;
} else { } else {
let Tag: VnodeElementTag = 'input'; let Tag: VnodeElementTag | typeof ColorPreviewInput = 'input';
if (type === ColorPreviewSettingType) { if (type === ColorPreviewSettingType) {
Tag = ColorPreviewInput; Tag = ColorPreviewInput;

View File

@ -79,9 +79,6 @@ export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IIn
} }
} }
/**
* @todo split into FormModal and Modal in 2.0
*/
view() { view() {
if (this.alertAttrs) { if (this.alertAttrs) {
this.alertAttrs.dismissible = false; this.alertAttrs.dismissible = false;

View File

@ -43,7 +43,7 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
data-modal-number={i} data-modal-number={i}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
style={{ '--modal-number': i }} style={!process.env.testing && { '--modal-number': i }}
aria-hidden={this.attrs.state.modal !== modal && 'true'} aria-hidden={this.attrs.state.modal !== modal && 'true'}
> >
{!!Tag && [ {!!Tag && [
@ -66,7 +66,7 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
className="Modal-backdrop backdrop" className="Modal-backdrop backdrop"
ontransitionend={this.onBackdropTransitionEnd.bind(this)} ontransitionend={this.onBackdropTransitionEnd.bind(this)}
data-showing={!!this.attrs.state.modalList.length || this.attrs.state.loadingModal} data-showing={!!this.attrs.state.modalList.length || this.attrs.state.loadingModal}
style={{ '--modal-count': this.attrs.state.modalList.length + Number(this.attrs.state.loadingModal) }} style={!process.env.testing && { '--modal-count': this.attrs.state.modalList.length + Number(this.attrs.state.loadingModal) }}
> >
{this.attrs.state.loadingModal && <LoadingIndicator />} {this.attrs.state.loadingModal && <LoadingIndicator />}
</div> </div>
@ -122,7 +122,9 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
this.focusTrap = createFocusTrap(this.activeDialogElement as HTMLElement, { allowOutsideClick: true }); this.focusTrap = createFocusTrap(this.activeDialogElement as HTMLElement, { allowOutsideClick: true });
this.focusTrap!.activate?.(); this.focusTrap!.activate?.();
disableBodyScroll(this.activeDialogManagerElement!, { reserveScrollBarGap: true }); if (this.activeDialogManagerElement) {
disableBodyScroll(this.activeDialogManagerElement, { reserveScrollBarGap: true });
}
} }
// Update key of current opened modal // Update key of current opened modal
@ -136,21 +138,21 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
/** /**
* Get current active dialog * Get current active dialog
*/ */
private get activeDialogElement(): HTMLElement { private get activeDialogElement(): HTMLElement | null {
return document.body.querySelector(`.ModalManager[data-modal-key="${this.attrs.state.modal?.key}"] .Modal`) as HTMLElement; return document.body.querySelector(`.ModalManager[data-modal-key="${this.attrs.state.modal?.key}"] .Modal`) as HTMLElement | null;
} }
/** /**
* Get current active dialog * Get current active dialog
*/ */
private get activeDialogManagerElement(): HTMLElement { private get activeDialogManagerElement(): HTMLElement | null {
return document.body.querySelector(`.ModalManager[data-modal-key="${this.attrs.state.modal?.key}"]`) as HTMLElement; return document.body.querySelector(`.ModalManager[data-modal-key="${this.attrs.state.modal?.key}"]`) as HTMLElement | null;
} }
animateShow(readyCallback: () => void = () => {}): void { animateShow(readyCallback: () => void = () => {}): void {
if (!this.attrs.state.modal) return; if (!this.attrs.state.modal) return;
this.activeDialogElement.addEventListener( this.activeDialogElement?.addEventListener(
'transitionend', 'transitionend',
() => { () => {
readyCallback(); readyCallback();
@ -159,7 +161,7 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
); );
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.activeDialogElement.classList.add('in'); this.activeDialogElement?.classList.add('in');
}); });
} }
@ -176,10 +178,10 @@ export default class ModalManager extends Component<IModalManagerAttrs> {
closedCallback(); closedCallback();
}; };
this.activeDialogElement.addEventListener('transitionend', afterModalClosedCallback, { once: true }); this.activeDialogElement?.addEventListener('transitionend', afterModalClosedCallback, { once: true });
this.activeDialogElement.classList.remove('in'); this.activeDialogElement?.classList.remove('in');
this.activeDialogElement.classList.add('out'); this.activeDialogElement?.classList.add('out');
} }
protected handleEscPress(e: KeyboardEvent): void { protected handleEscPress(e: KeyboardEvent): void {

View File

@ -1,38 +1,14 @@
import app from '../app'; import app from '../app';
import Component, { type ComponentAttrs } from '../Component';
import classList from '../utils/classList'; import classList from '../utils/classList';
import Dropdown from './Dropdown'; import Dropdown from './Dropdown';
import Mithril from 'mithril'; import Mithril from 'mithril';
import Button from './Button'; import Button from './Button';
import Tooltip from './Tooltip'; import Tooltip from './Tooltip';
import Select, { ISelectAttrs, Option } from './Select';
export type Option = { export interface IMultiSelectAttrs extends ISelectAttrs {}
label: string;
disabled?: boolean | ((value: string[]) => boolean);
tooltip?: string;
};
export interface IMultiSelectAttrs extends ComponentAttrs { export default class MultiSelect<CustomAttrs extends IMultiSelectAttrs = IMultiSelectAttrs> extends Select<CustomAttrs> {
options: Record<string, string | Option>;
onchange?: (value: string[]) => void;
value?: string[];
disabled?: boolean;
wrapperAttrs?: Record<string, string>;
}
/**
* The `MultiSelect` component displays an input with selected elements.
* With a dropdown to select multiple options.
*
* - `options` A map of option values to labels.
* - `onchange` A callback to run when the selected value is changed.
* - `value` The value of the selected option.
* - `disabled` Disabled state for the input.
* - `wrapperAttrs` A map of attrs to be passed to the DOM element wrapping the input.
*
* Other attributes are passed directly to the input element rendered to the DOM.
*/
export default class MultiSelect<CustomAttrs extends IMultiSelectAttrs = IMultiSelectAttrs> extends Component<CustomAttrs> {
protected selected: string[] = []; protected selected: string[] = [];
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) { oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
@ -41,7 +17,7 @@ export default class MultiSelect<CustomAttrs extends IMultiSelectAttrs = IMultiS
this.selected = this.attrs.value || []; this.selected = this.attrs.value || [];
} }
view() { input(): JSX.Element {
const { const {
options, options,
onchange, onchange,
@ -49,15 +25,10 @@ export default class MultiSelect<CustomAttrs extends IMultiSelectAttrs = IMultiS
className, className,
class: _class, class: _class,
// Destructure the `wrapperAttrs` object to extract the `className` for passing to `classList()`
// `= {}` prevents errors when `wrapperAttrs` is undefined
wrapperAttrs: { className: wrapperClassName, class: wrapperClass, ...wrapperAttrs } = {},
...domAttrs ...domAttrs
} = this.attrs; } = this.attrs;
return ( return (
<span className={classList('Select MultiSelect', wrapperClassName, wrapperClass)} {...wrapperAttrs}>
<Dropdown <Dropdown
disabled={disabled} disabled={disabled}
buttonClassName="Button" buttonClassName="Button"
@ -82,7 +53,7 @@ export default class MultiSelect<CustomAttrs extends IMultiSelectAttrs = IMultiS
const button = ( const button = (
<Button <Button
type="button" type="button"
className={classList('Dropdown-item', { disabled })} className={classList('Dropdown-item', `Dropdown-item--${key}`, { disabled })}
onclick={this.toggle.bind(this, key)} onclick={this.toggle.bind(this, key)}
disabled={disabled} disabled={disabled}
icon={this.selected.includes(key) ? 'fas fa-check' : 'fas fa-empty'} icon={this.selected.includes(key) ? 'fas fa-check' : 'fas fa-empty'}
@ -98,7 +69,6 @@ export default class MultiSelect<CustomAttrs extends IMultiSelectAttrs = IMultiS
return button; return button;
})} })}
</Dropdown> </Dropdown>
</span>
); );
} }

View File

@ -1,8 +1,22 @@
import Component from '../Component'; import Component, { type ComponentAttrs } from '../Component';
import withAttr from '../utils/withAttr'; import withAttr from '../utils/withAttr';
import classList from '../utils/classList'; import classList from '../utils/classList';
import Icon from './Icon'; import Icon from './Icon';
export type Option = {
label: string;
disabled?: boolean | ((value: any) => boolean);
tooltip?: string;
};
export interface ISelectAttrs extends ComponentAttrs {
options: Record<string, string | Option>;
onchange?: (value: any) => void;
value?: any;
disabled?: boolean;
wrapperAttrs?: Record<string, string>;
}
/** /**
* The `Select` component displays a <select> input, surrounded with some extra * The `Select` component displays a <select> input, surrounded with some extra
* elements for styling. It accepts the following attrs: * elements for styling. It accepts the following attrs:
@ -15,8 +29,22 @@ import Icon from './Icon';
* *
* Other attributes are passed directly to the `<select>` element rendered to the DOM. * Other attributes are passed directly to the `<select>` element rendered to the DOM.
*/ */
export default class Select extends Component { export default class Select<CustomAttrs extends ISelectAttrs = ISelectAttrs> extends Component<CustomAttrs> {
view() { view() {
const {
// Destructure the `wrapperAttrs` object to extract the `className` for passing to `classList()`
// `= {}` prevents errors when `wrapperAttrs` is undefined
wrapperAttrs: { className: wrapperClassName, class: wrapperClass, ...wrapperAttrs } = {},
} = this.attrs;
return (
<span className={classList('Select', wrapperClassName, wrapperClass)} {...wrapperAttrs}>
{this.input()}
</span>
);
}
input() {
const { const {
options, options,
onchange, onchange,
@ -25,15 +53,11 @@ export default class Select extends Component {
className, className,
class: _class, class: _class,
// Destructure the `wrapperAttrs` object to extract the `className` for passing to `classList()`
// `= {}` prevents errors when `wrapperAttrs` is undefined
wrapperAttrs: { className: wrapperClassName, class: wrapperClass, ...wrapperAttrs } = {},
...domAttrs ...domAttrs
} = this.attrs; } = this.attrs;
return ( return (
<span className={classList('Select', wrapperClassName, wrapperClass)} {...wrapperAttrs}> <>
<select <select
className={classList('Select-input FormControl', className, _class)} className={classList('Select-input FormControl', className, _class)}
onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined} onchange={onchange ? withAttr('value', onchange.bind(this)) : undefined}
@ -43,15 +67,11 @@ export default class Select extends Component {
> >
{Object.keys(options).map((key) => { {Object.keys(options).map((key) => {
const option = options[key]; const option = options[key];
const label = typeof option === 'object' && 'label' in option ? option.label : option;
let disabled = typeof option === 'object' && 'disabled' in option ? option.disabled : false;
let label; if (typeof disabled === 'function') {
let disabled = false; disabled = disabled(value ?? null);
if (typeof option === 'object' && option.label) {
label = option.label;
disabled = option.disabled ?? false;
} else {
label = option;
} }
return ( return (
@ -62,7 +82,7 @@ export default class Select extends Component {
})} })}
</select> </select>
<Icon name="fas fa-sort" className="Select-caret" /> <Icon name="fas fa-sort" className="Select-caret" />
</span> </>
); );
} }
} }

View File

@ -35,7 +35,7 @@ export function slug(string: string, mode: SluggingMode = SluggingMode.ALPHANUME
} }
} }
enum SluggingMode { export enum SluggingMode {
ALPHANUMERIC = 'alphanum', ALPHANUMERIC = 'alphanum',
UTF8 = 'utf8', UTF8 = 'utf8',
} }

View File

@ -8,6 +8,7 @@ import ItemList from '../../common/utils/ItemList';
import Tooltip from '../../common/components/Tooltip'; import Tooltip from '../../common/components/Tooltip';
import HeaderList from './HeaderList'; import HeaderList from './HeaderList';
import HeaderListGroup from './HeaderListGroup'; import HeaderListGroup from './HeaderListGroup';
import NotificationType from './NotificationType';
/** /**
* The `NotificationList` component displays a list of the logged-in user's * The `NotificationList` component displays a list of the logged-in user's
@ -116,12 +117,7 @@ export default class NotificationList extends Component {
) )
} }
> >
{group.notifications {group.notifications.map((notification) => <NotificationType notification={notification} />).filter((component) => !!component)}
.map((notification) => {
const NotificationComponent = app.notificationComponents[notification.contentType()];
return !!NotificationComponent ? <NotificationComponent notification={notification} /> : null;
})
.filter((component) => !!component)}
</HeaderListGroup> </HeaderListGroup>
); );
}); });

View File

@ -0,0 +1,16 @@
import Component, { type ComponentAttrs } from '../../common/Component';
import type Notification from '../../common/models/Notification';
import app from '../app';
export interface INotificationTypeAttrs extends ComponentAttrs {
notification: Notification;
}
export default class NotificationType<CustomAttrs extends INotificationTypeAttrs = INotificationTypeAttrs> extends Component<CustomAttrs> {
view() {
const notification = this.attrs.notification;
const NotificationComponent = app.notificationComponents[notification.contentType()!];
return !!NotificationComponent ? <NotificationComponent notification={notification} /> : null;
}
}

View File

@ -5,6 +5,7 @@ import LoadingPost from './LoadingPost';
import ReplyPlaceholder from './ReplyPlaceholder'; import ReplyPlaceholder from './ReplyPlaceholder';
import Button from '../../common/components/Button'; import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList'; import ItemList from '../../common/utils/ItemList';
import PostType from './PostType';
/** /**
* The `PostStream` component displays an infinitely-scrollable wall of posts in * The `PostStream` component displays an infinitely-scrollable wall of posts in
@ -47,8 +48,7 @@ export default class PostStream extends Component {
if (post) { if (post) {
const time = post.createdAt(); const time = post.createdAt();
const PostComponent = app.postComponents[post.contentType()]; content = <PostType post={post} />;
content = !!PostComponent && <PostComponent post={post} />;
attrs.key = 'post' + post.id(); attrs.key = 'post' + post.id();
attrs.oncreate = postFadeIn; attrs.oncreate = postFadeIn;

View File

@ -0,0 +1,16 @@
import Component, { type ComponentAttrs } from '../../common/Component';
import type Post from '../../common/models/Post';
import app from '../app';
export interface IPostTypeAttrs extends ComponentAttrs {
post: Post;
}
export default class PostType<CustomAttrs extends IPostTypeAttrs = IPostTypeAttrs> extends Component<CustomAttrs> {
view() {
const post = this.attrs.post;
const PostComponent = app.postComponents[post.contentType()!];
return !!PostComponent && <PostComponent post={post} />;
}
}

View File

@ -30,7 +30,7 @@ export default class UserCard extends Component {
const color = user.color(); const color = user.color();
return ( return (
<div className={classList('UserCard', this.attrs.className)} style={color && { '--usercard-bg': color }}> <div className={classList('UserCard', this.attrs.className)} style={color && !process.env.testing && { '--usercard-bg': color }}>
<div className="darkenBackground"> <div className="darkenBackground">
<div className="container"> <div className="container">
<div className="UserCard-profile">{this.profileItems().toArray()}</div> <div className="UserCard-profile">{this.profileItems().toArray()}</div>

View File

@ -0,0 +1,58 @@
export function makeDiscussion(data: any = {}) {
return makeModel('discussions', data.id, {
attributes: {
title: 'Discussion 1',
slug: 'discussion-1',
lastPostedAt: new Date().toISOString(),
unreadCount: 0,
commentCount: 0,
...(data.attributes || {}),
},
relationships: {
firstPost: {
data: { type: 'posts', id: '1' },
},
user: {
data: { type: 'users', id: '1' },
},
...(data.relationships || {}),
},
});
}
export function makeUser(data: any = {}) {
return makeModel('users', data.id, {
attributes: {
id: data.id,
username: 'user' + data.id,
displayName: 'User ' + data.id,
email: `user${data.id}@machine.local`,
joinTime: '2021-01-01T00:00:00Z',
isEmailConfirmed: true,
preferences: {},
...(data.attributes || {}),
},
relationships: {
groups: {
data: [],
},
...(data.relationships || {}),
},
});
}
function makeModel(type: string | undefined | null, id: string | number | undefined | null, data: any) {
if (!id) {
throw new Error('You must provide an id when making a model');
}
if (!type) {
throw new Error('You must provide a type when making a model');
}
return {
type,
id,
...data,
};
}

View File

@ -0,0 +1,18 @@
import bootstrapAdmin from '@flarum/jest-config/src/boostrap/admin';
import AdvancedPage from '../../../../src/admin/components/AdvancedPage';
import { app } from '../../../../src/admin';
import mq from 'mithril-query';
beforeAll(() => bootstrapAdmin());
describe('AdvancedPage', () => {
beforeAll(() => {
app.boot();
});
test('it renders', () => {
const page = mq(AdvancedPage);
expect(page).toHaveElement('.AdvancedPage');
});
});

View File

@ -0,0 +1,18 @@
import bootstrapAdmin from '@flarum/jest-config/src/boostrap/admin';
import AppearancePage from '../../../../src/admin/components/AppearancePage';
import { app } from '../../../../src/admin';
import mq from 'mithril-query';
beforeAll(() => bootstrapAdmin());
describe('AppearancePage', () => {
beforeAll(() => {
app.boot();
});
test('it renders', () => {
const page = mq(AppearancePage);
expect(page).toHaveElement('.AppearancePage');
});
});

View File

@ -0,0 +1,18 @@
import bootstrapAdmin from '@flarum/jest-config/src/boostrap/admin';
import BasicsPage from '../../../../src/admin/components/BasicsPage';
import { app } from '../../../../src/admin';
import mq from 'mithril-query';
beforeAll(() => bootstrapAdmin());
describe('BasicsPage', () => {
beforeAll(() => {
app.boot();
});
test('it renders', () => {
const page = mq(BasicsPage);
expect(page).toHaveElement('.BasicsPage');
});
});

View File

@ -0,0 +1,18 @@
import bootstrapAdmin from '@flarum/jest-config/src/boostrap/admin';
import DashboardPage from '../../../../src/admin/components/DashboardPage';
import { app } from '../../../../src/admin';
import mq from 'mithril-query';
beforeAll(() => bootstrapAdmin());
describe('DashboardPage', () => {
beforeAll(() => {
app.boot();
});
test('it renders', () => {
const page = mq(DashboardPage);
expect(page).toHaveElement('.DashboardPage');
});
});

View File

@ -0,0 +1,18 @@
import bootstrapAdmin from '@flarum/jest-config/src/boostrap/admin';
import MailPage from '../../../../src/admin/components/MailPage';
import { app } from '../../../../src/admin';
import mq from 'mithril-query';
beforeAll(() => bootstrapAdmin());
describe('MailPage', () => {
beforeAll(() => {
app.boot();
});
test('it renders', () => {
const page = mq(MailPage);
expect(page).toHaveElement('.MailPage');
});
});

View File

@ -0,0 +1,37 @@
import bootstrapAdmin from '@flarum/jest-config/src/boostrap/admin';
import mq from 'mithril-query';
import { app } from '../../../../src/admin';
import ModalManager from '../../../../src/common/components/ModalManager';
import CreateUserModal from '../../../../src/admin/components/CreateUserModal';
import EditCustomCssModal from '../../../../src/admin/components/EditCustomCssModal';
import EditCustomFooterModal from '../../../../src/admin/components/EditCustomFooterModal';
import EditCustomHeaderModal from '../../../../src/admin/components/EditCustomHeaderModal';
import EditGroupModal from '../../../../src/admin/components/EditGroupModal';
import LoadingModal from '../../../../src/admin/components/LoadingModal';
import ReadmeModal from '../../../../src/admin/components/ReadmeModal';
beforeAll(() => bootstrapAdmin());
describe('Modals', () => {
beforeAll(() => app.boot());
beforeEach(() => app.modal.close());
test.each([
[CreateUserModal, {}],
[EditCustomCssModal, {}],
[EditCustomFooterModal, {}],
[EditCustomHeaderModal, {}],
[EditGroupModal, {}],
[LoadingModal, {}],
[ReadmeModal, { extension: { id: 'flarum-test', extra: { 'flarum-extension': { title: 'Test' } } } }],
])('modal renders', (modal, attrs) => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(modal, attrs || {});
manager.redraw();
expect(app.modal.modalList.length).toEqual(1);
expect(manager).toHaveElement('.ModalManager');
});
});

View File

@ -0,0 +1,18 @@
import bootstrapAdmin from '@flarum/jest-config/src/boostrap/admin';
import PermissionsPage from '../../../../src/admin/components/PermissionsPage';
import { app } from '../../../../src/admin';
import mq from 'mithril-query';
beforeAll(() => bootstrapAdmin());
describe('PermissionsPage', () => {
beforeAll(() => {
app.boot();
});
test('it renders', () => {
const page = mq(PermissionsPage);
expect(page).toHaveElement('.PermissionsPage');
});
});

View File

@ -0,0 +1,18 @@
import bootstrapAdmin from '@flarum/jest-config/src/boostrap/admin';
import UserListPage from '../../../../src/admin/components/UserListPage';
import { app } from '../../../../src/admin';
import mq from 'mithril-query';
beforeAll(() => bootstrapAdmin());
describe('UserListPage', () => {
beforeAll(() => {
app.boot();
});
test('it renders', () => {
const page = mq(UserListPage);
expect(page).toHaveElement('.UserListPage');
});
});

View File

@ -0,0 +1,45 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import { app } from '../../../src/forum';
import mq from 'mithril-query';
import AlertManager from '../../../src/common/components/AlertManager';
beforeAll(() => bootstrapForum());
describe('AlertManager', () => {
beforeAll(() => app.boot());
test('can show and dismiss an alert', () => {
const manager = mq(AlertManager, { state: app.alerts });
const id = app.alerts.show({ type: 'success' }, 'Hello, world!');
manager.redraw();
expect(manager).toContainRaw('Hello, world!');
app.alerts.dismiss(id);
manager.redraw();
expect(manager).not.toContainRaw('Hello, world!');
});
test('can clear all alerts', () => {
const manager = mq(AlertManager, { state: app.alerts });
app.alerts.show({ type: 'success' }, 'Hello, world!');
app.alerts.show({ type: 'error' }, 'Goodbye, world!');
manager.redraw();
expect(manager).toContainRaw('Hello, world!');
expect(manager).toContainRaw('Goodbye, world!');
app.alerts.clear();
manager.redraw();
expect(manager).not.toContainRaw('Hello, world!');
expect(manager).not.toContainRaw('Goodbye, world!');
});
});

View File

@ -0,0 +1,46 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import GambitManager from '../../../src/common/GambitManager';
import { app } from '../../../src/forum';
const gambits = new GambitManager();
beforeAll(() => bootstrapForum());
describe('GambitManager', () => {
beforeAll(() => {
app.boot();
});
test('gambits are converted to filters', function () {
expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07 is:hidden author:behz' })).toStrictEqual({
q: 'lorem',
created: '2023-07-07',
hidden: true,
author: 'behz',
});
});
test('gambits are negated when prefixed with a dash', function () {
expect(gambits.apply('discussions', { q: 'lorem -created:2023-07-07 -is:hidden -author:behz' })).toStrictEqual({
q: 'lorem',
'-created': '2023-07-07',
'-hidden': true,
'-author': 'behz',
});
});
test('gambits are only applied for the correct resource type', function () {
expect(gambits.apply('users', { q: 'lorem created:2023-07-07 is:hidden author:behz email:behz@machine.local' })).toStrictEqual({
q: 'lorem created:2023-07-07 is:hidden author:behz',
email: 'behz@machine.local',
});
expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07..2023-10-18 is:hidden -author:behz email:behz@machine.local' })).toStrictEqual(
{
q: 'lorem email:behz@machine.local',
created: '2023-07-07..2023-10-18',
hidden: true,
'-author': 'behz',
}
);
});
});

View File

@ -0,0 +1,93 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import { app } from '../../../src/forum';
import mq from 'mithril-query';
import ModalManager from '../../../src/common/components/ModalManager';
import Modal from '../../../src/common/components/Modal';
beforeAll(() => bootstrapForum());
describe('ModalManager', () => {
beforeAll(() => app.boot());
test('can show and close a modal', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(MyModal);
manager.redraw();
expect(manager).toHaveElement('.ModalManager');
expect(manager).toHaveElement('.ModalManager[data-modal-number="0"]');
expect(manager).toHaveElement('.Modal');
expect(manager).toContainRaw('Hello, world!');
app.modal.close();
manager.redraw();
expect(manager).not.toHaveElement('.Modal');
expect(manager).not.toContainRaw('Hello, world!');
});
test('can stack modals', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(MyModal);
app.modal.show(MySecondModal, {}, true);
manager.redraw();
expect(manager).toHaveElement('.ModalManager[data-modal-number="0"]');
expect(manager).toHaveElement('.ModalManager[data-modal-number="1"]');
expect(manager).toHaveElement('.Modal');
expect(manager).toContainRaw('Hello, world!');
expect(manager).toContainRaw('Really cool content');
app.modal.close();
manager.redraw();
expect(manager).toHaveElement('.ModalManager[data-modal-number="0"]');
expect(manager).not.toHaveElement('.ModalManager[data-modal-number="1"]');
expect(manager).toHaveElement('.Modal');
expect(manager).not.toContainRaw('Really cool content');
expect(manager).toContainRaw('Hello, world!');
app.modal.close();
manager.redraw();
expect(manager).not.toHaveElement('.ModalManager[data-modal-number="0"]');
expect(manager).not.toHaveElement('.ModalManager[data-modal-number="1"]');
expect(manager).not.toHaveElement('.Modal');
expect(manager).not.toContainRaw('Hello, world!');
});
});
class MyModal extends Modal {
className(): string {
return '';
}
content() {
return 'Hello, world!';
}
title() {
return 'My Modal';
}
}
class MySecondModal extends Modal {
className(): string {
return '';
}
content() {
return 'Really cool content';
}
title() {
return 'My Second Modal';
}
}

View File

@ -1,8 +1,11 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Alert from '../../../../src/common/components/Alert'; import Alert from '../../../../src/common/components/Alert';
import m from 'mithril'; import m from 'mithril';
import mq from 'mithril-query'; import mq from 'mithril-query';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
beforeAll(() => bootstrapForum());
describe('Alert displays as expected', () => { describe('Alert displays as expected', () => {
it('should display alert messages with an icon', () => { it('should display alert messages with an icon', () => {
const alert = mq(m(Alert, { type: 'error' }, 'Shoot!')); const alert = mq(m(Alert, { type: 'error' }, 'Shoot!'));

View File

@ -0,0 +1,28 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Avatar from '../../../../src/common/components/Avatar';
import m from 'mithril';
import mq from 'mithril-query';
import User from '../../../../src/common/models/User';
beforeAll(() => bootstrapForum());
describe('Avatar displays as expected', () => {
it('renders avatar when user is deleted', () => {
const avatar = mq(m(Avatar, { user: null, className: 'test' }));
expect(avatar).toHaveElement('span.Avatar.test');
});
it('renders avatar when user has avatarUrl', () => {
const user = new User({
attributes: {
avatarUrl: 'http://example.com/avatar.png',
color: '#000000',
username: 'test',
displayName: 'test',
},
});
const avatar = mq(m(Avatar, { user }));
expect(avatar).toHaveElement('img.Avatar');
expect(avatar).toHaveElementAttr('img.Avatar', 'src', 'http://example.com/avatar.png');
});
});

View File

@ -0,0 +1,29 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Badge from '../../../../src/common/components/Badge';
import m from 'mithril';
import mq from 'mithril-query';
beforeAll(() => bootstrapForum());
describe('Badge displays as expected', () => {
it('renders badge without a tooltip', () => {
const badge = mq(
m(Badge, {
icon: 'fas fa-user',
type: 'Test',
})
);
expect(badge).toHaveElement('.Badge.Badge--Test');
});
it('renders badge with a tooltip', () => {
const badge = mq(
m(Badge, {
icon: 'fas fa-user',
type: 'Test',
label: 'Tooltip',
})
);
expect(badge).toHaveElement('.Badge.Badge--Test');
});
});

View File

@ -0,0 +1,72 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Button from '../../../../src/common/components/Button';
import m from 'mithril';
import mq from 'mithril-query';
import { jest } from '@jest/globals';
import { app } from '../../../../src/forum';
beforeAll(() => bootstrapForum());
describe('Button displays as expected', () => {
beforeAll(() => {
app.boot();
});
it('renders button with text and icon', () => {
const button = mq(
m(
Button,
{
icon: 'fas fa-check',
'aria-label': 'Aria label',
},
'Submit'
)
);
expect(button).toHaveElement('button.hasIcon');
expect(button).toHaveElementAttr('button', 'aria-label', 'Aria label');
expect(button).toHaveElement('.Button-label');
expect(button).toContainRaw('Submit');
expect(button).toHaveElement('.icon.fas.fa-check');
});
it('can be disabled', () => {
const onclick = jest.fn();
const button = mq(
Button,
{
disabled: true,
onclick,
},
'Submit'
);
expect(button).toHaveElement('button.disabled');
button.click('button');
expect(onclick).not.toHaveBeenCalled();
});
it('can be clicked', () => {
const onclick = jest.fn();
const button = mq(Button, { onclick });
button.click('button');
expect(onclick).toHaveBeenCalled();
});
it('can be loading', () => {
const onclick = jest.fn();
const button = mq(
Button,
{
loading: true,
onclick,
},
'Submit'
);
expect(button).toHaveElement('.loading');
button.click('button');
expect(onclick).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,33 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Checkbox from '../../../../src/common/components/Checkbox';
import m from 'mithril';
import mq from 'mithril-query';
import { jest } from '@jest/globals';
beforeAll(() => bootstrapForum());
describe('Checkbox displays as expected', () => {
it('renders checkbox with text', () => {
const checkbox = mq(
m(
Checkbox,
{
state: true,
onchange: jest.fn(),
},
'Toggle This For Me'
)
);
expect(checkbox).toHaveElement('label.Checkbox.on');
expect(checkbox).toContainRaw('Toggle This For Me');
});
it('can be toggled', () => {
const onchange = jest.fn();
const checkbox = mq(Checkbox, { onchange, state: true });
// @ts-ignore
checkbox.trigger('input', 'change', { target: new EventTarget() });
expect(onchange).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,32 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import ColorPreviewInput from '../../../../src/common/components/ColorPreviewInput';
import m from 'mithril';
import mq from 'mithril-query';
import { jest } from '@jest/globals';
beforeAll(() => bootstrapForum());
describe('ColorPreviewInput displays as expected', () => {
it('renders', () => {
const input = mq(m(ColorPreviewInput, { value: '#000000' }));
expect(input).toHaveElement('.FormControl');
});
it('handles correct values', () => {
const onchange = jest.fn();
const input = mq(ColorPreviewInput, { value: '#000000', onchange });
// @ts-ignore
input.trigger('input[type=color]', 'blur', { target: {} });
expect(onchange).toHaveBeenCalledTimes(0);
});
it('handles wrong values', () => {
const onchange = jest.fn();
const input = mq(ColorPreviewInput, { value: '#fe', onchange });
// @ts-ignore
input.trigger('input[type=color]', 'blur', { target: {} });
expect(onchange).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,31 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Dropdown from '../../../../src/common/components/Dropdown';
import Button from '../../../../src/common/components/Button';
import m from 'mithril';
import mq from 'mithril-query';
import { jest } from '@jest/globals';
beforeAll(() => bootstrapForum());
describe('Dropdown displays as expected', () => {
it('renders', () => {
const dropdown = mq(
m(
Dropdown,
{
label: 'click me!',
icon: 'fas fa-cog',
tooltip: 'tooltip!',
caretIcon: 'fas fa-caret-down',
buttonClassName: 'buttonClassName',
accessibleToggleLabel: 'toggle',
menuClassName: 'menuClassName',
onshow: jest.fn(),
onhide: jest.fn(),
},
[m(Button, { onclick: jest.fn() }, 'button 1'), m(Button, { onclick: jest.fn() }, 'button 2')]
)
);
expect(dropdown).toContainRaw('click me!');
});
});

View File

@ -0,0 +1,24 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import FieldSet from '../../../../src/common/components/FieldSet';
import m from 'mithril';
import mq from 'mithril-query';
beforeAll(() => bootstrapForum());
describe('FieldSet displays as expected', () => {
it('renders', () => {
const input = mq(
m(
FieldSet,
{
label: 'Test FieldSet',
description: 'This is a test fieldset',
},
'child nodes'
)
);
expect(input).toContainRaw('Test FieldSet');
expect(input).toContainRaw('This is a test fieldset');
expect(input).toContainRaw('child nodes');
});
});

View File

@ -0,0 +1,59 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import FormModal from '../../../../src/common/components/FormModal';
import ModalManagerState from '../../../../src/common/states/ModalManagerState';
import m from 'mithril';
import mq from 'mithril-query';
import { jest } from '@jest/globals';
beforeAll(() => bootstrapForum());
describe('FormModal displays as expected', () => {
it('renders', () => {
const modal = mq(
m(TestFormModal, {
state: new ModalManagerState(),
animateShow: () => {},
animateHide: () => {},
})
);
expect(modal).toHaveElement('form');
expect(modal).toContainRaw('test title');
expect(modal).toContainRaw('test content');
expect(modal).toHaveElement('.TestClass');
});
it('submits', () => {
const onsubmit = jest.fn();
const modal = mq(TestFormModal, {
state: new ModalManagerState(),
animateShow: () => {},
animateHide: () => {},
onsubmit,
});
// @ts-ignore
modal.trigger('form', 'submit', {});
expect(onsubmit).toHaveBeenCalled();
});
});
class TestFormModal extends FormModal {
className(): string {
return 'TestClass';
}
content() {
return 'test content';
}
title() {
return 'test title';
}
onsubmit(e: SubmitEvent) {
// @ts-ignore
this.attrs.onsubmit?.(e);
}
}

View File

@ -0,0 +1,17 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Icon from '../../../../src/common/components/Icon';
import m from 'mithril';
import mq from 'mithril-query';
beforeAll(() => bootstrapForum());
describe('Icon displays as expected', () => {
it('renders', () => {
const icon = mq(
m(Icon, {
name: 'fas fa-user',
})
);
expect(icon).toHaveElement('.fas.fa-user');
});
});

View File

@ -0,0 +1,53 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Input from '../../../../src/common/components/Input';
import m from 'mithril';
import mq from 'mithril-query';
import { jest } from '@jest/globals';
beforeAll(() => bootstrapForum());
describe('Input displays as expected', () => {
it('renders', () => {
const input = mq(
m(Input, {
placeholder: 'Lorem',
value: 'Ipsum',
prefixIcon: 'fas fa-user',
ariaLabel: 'Dolor',
})
);
expect(input).toHaveElement('input');
expect(input).toContainRaw('Ipsum');
expect(input).toHaveElementAttr('input', 'aria-label', 'Dolor');
expect(input).toHaveElementAttr('input', 'placeholder', 'Lorem');
expect(input).toHaveElement('.fas.fa-user');
});
it('can be cleared', () => {
const onchange = jest.fn();
const input = mq(Input, {
clearable: true,
clearLabel: 'Clear',
onchange,
value: 'Ipsum',
});
expect(input).toHaveElementAttr('.Input-clear', 'aria-label', 'Clear');
input.click('.Input-clear');
expect(onchange).toHaveBeenCalledWith('');
});
it('can be loading', () => {
const input = mq(Input, {
loading: true,
});
expect(input).toHaveElement('.LoadingIndicator');
});
it('can be disabled', () => {
const input = mq(Input, {
disabled: true,
});
expect(input).toHaveElementAttr('input', 'disabled');
});
});

View File

@ -0,0 +1,27 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Link from '../../../../src/common/components/Link';
import LinkButton from '../../../../src/common/components/LinkButton';
import m from 'mithril';
import mq from 'mithril-query';
beforeAll(() => bootstrapForum());
describe('Link displays as expected', () => {
it('renders as simple link', () => {
const link = mq(
m(Link, {
href: '/user',
})
);
expect(link).toHaveElement('a');
});
it('renders as button link', () => {
const link = mq(
m(LinkButton, {
href: '/user',
})
);
expect(link).toHaveElement('a');
});
});

View File

@ -0,0 +1,22 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import LoadingIndicator from '../../../../src/common/components/LoadingIndicator';
import m from 'mithril';
import mq from 'mithril-query';
beforeAll(() => bootstrapForum());
describe('LoadingIndicator displays as expected', () => {
it('renders as simple link', () => {
const indicator = mq(
m(LoadingIndicator, {
display: 'block',
size: 'large',
containerClassName: 'container',
containerAttrs: { 'data-test': 'test' },
className: 'indicator',
})
);
expect(indicator).toHaveElementAttr('.container', 'data-test', 'test');
expect(indicator).toHaveElement('.indicator');
});
});

View File

@ -0,0 +1,76 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Modal from '../../../../src/common/components/Modal';
import ModalManagerState from '../../../../src/common/states/ModalManagerState';
import m from 'mithril';
import mq from 'mithril-query';
import { jest } from '@jest/globals';
beforeAll(() => bootstrapForum());
describe('Modal displays as expected', () => {
it('renders', () => {
const modal = mq(
m(TestModal, {
state: new ModalManagerState(),
animateShow: () => {},
animateHide: () => {},
})
);
expect(modal).toContainRaw('test title');
expect(modal).toContainRaw('test content');
expect(modal).toHaveElement('.TestClass');
});
it('can be dismissed via close button', () => {
const animateHide = jest.fn();
const modal = mq(TestModal, {
state: new ModalManagerState(),
animateShow: () => {},
animateHide,
});
modal.click('.Modal-close button');
expect(animateHide).toHaveBeenCalled();
});
it('cannot be dismissed via close button', () => {
const modal = mq(UndismissableModal, {
state: new ModalManagerState(),
animateShow: () => {},
animateHide: () => {},
});
expect(modal).not.toHaveElement('.Modal-close button');
});
});
class TestModal extends Modal {
className(): string {
return 'TestClass';
}
content() {
return 'test content';
}
title() {
return 'test title';
}
}
class UndismissableModal extends Modal {
protected static readonly isDismissibleViaCloseButton: boolean = false;
className(): string {
return 'UndismissableModal';
}
content() {
return 'test undismissable content';
}
title() {
return 'test undismissable title';
}
}

View File

@ -0,0 +1,52 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import MultiSelect from '../../../../src/common/components/MultiSelect';
import mq from 'mithril-query';
import { jest } from '@jest/globals';
beforeAll(() => bootstrapForum());
describe('MultiSelect displays as expected', () => {
it('works as expected', () => {
const onchange = jest.fn();
const select = mq(MultiSelect, {
options: {
a: 'Option A',
b: 'Option B',
c: {
label: 'Option C',
disabled: true,
tooltip: 'Disabled',
},
},
value: ['b'],
wrapperAttrs: { 'data-test': 'test' },
className: 'select',
onchange,
});
expect(select).toHaveElementAttr('.Select', 'data-test', 'test');
expect(select).toContainRaw('Option A');
expect(select).toContainRaw('Option B');
expect(select).toContainRaw('Option C');
select.click('.Dropdown-item--a');
expect(onchange).toHaveBeenCalledTimes(1);
expect(onchange).toHaveBeenCalledWith(['b', 'a']);
select.click('.Dropdown-item--b');
expect(onchange).toHaveBeenCalledTimes(2);
expect(onchange).toHaveBeenCalledWith(['a']);
select.click('.Dropdown-item--c');
expect(onchange).toHaveBeenCalledTimes(2);
expect(onchange).toHaveBeenCalledWith(['a']);
select.click('.Dropdown-item--a');
expect(onchange).toHaveBeenCalledTimes(3);
expect(onchange).toHaveBeenCalledWith([]);
select.click('.Dropdown-item--b');
expect(onchange).toHaveBeenCalledTimes(4);
expect(onchange).toHaveBeenCalledWith(['b']);
});
});

View File

@ -0,0 +1,22 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Navigation from '../../../../src/common/components/Navigation';
import { app } from '../../../../src/forum';
import mq from 'mithril-query';
beforeAll(() => bootstrapForum());
describe('Navigation', () => {
beforeAll(() => app.boot());
test('renders as normal nav', () => {
const nav = mq(Navigation);
expect(nav).toBeTruthy();
});
test('renders as drawer', () => {
const nav = mq(Navigation, { drawer: true });
expect(nav).toBeTruthy();
});
});

View File

@ -0,0 +1,39 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Select from '../../../../src/common/components/Select';
import mq from 'mithril-query';
import { jest } from '@jest/globals';
beforeAll(() => bootstrapForum());
describe('Select displays as expected', () => {
it('works as expected', () => {
const onchange = jest.fn();
const select = mq(Select, {
options: {
a: 'Option A',
b: 'Option B',
c: {
label: 'Option C',
disabled: true,
},
},
value: null,
wrapperAttrs: { 'data-test': 'test' },
className: 'select',
onchange,
});
expect(select).toHaveElementAttr('.Select', 'data-test', 'test');
expect(select).toContainRaw('Option A');
expect(select).toContainRaw('Option B');
expect(select).toContainRaw('Option C');
select.setValue('select', 'a');
expect(onchange).toHaveBeenCalledTimes(1);
expect(onchange).toHaveBeenCalledWith('a');
select.setValue('select', 'b');
expect(onchange).toHaveBeenCalledTimes(2);
expect(onchange).toHaveBeenCalledWith('b');
});
});

View File

@ -0,0 +1,54 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import SelectDropdown from '../../../../src/common/components/SelectDropdown';
import mq from 'mithril-query';
import m from 'mithril';
beforeAll(() => bootstrapForum());
describe('SelectDropdown displays as expected', () => {
it('works as expected', () => {
const buttons = [
m('button', { className: 'button-1' }, 'Option A'),
m('button', { className: 'button-2' }, 'Option B'),
m('button', { className: 'button-3' }, 'Option C'),
];
const select = mq(
m(
SelectDropdown,
{
label: 'Select the option',
defaultLabel: 'Select an option',
},
buttons
)
);
expect(select).toContainRaw('Select an option');
expect(select).toContainRaw('Option A');
expect(select).toContainRaw('Option B');
expect(select).toContainRaw('Option C');
});
it('uses active button as label', () => {
const buttons = [
m('button', { className: 'button-1', active: false }, 'Option A'),
m('button', { className: 'button-2', active: true }, 'Option B'),
m('button', { className: 'button-3', active: false }, 'Option C'),
];
const select = mq(
m(
SelectDropdown,
{
label: 'Select the option',
defaultLabel: 'Select an option',
},
buttons
)
);
expect(select).toContainRaw('Option B');
expect(select).not.toContainRaw('Select an option');
});
});

View File

@ -0,0 +1,39 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import SplitDropdown from '../../../../src/common/components/SplitDropdown';
import mq from 'mithril-query';
import m from 'mithril';
beforeAll(() => bootstrapForum());
describe('SplitDropdown displays as expected', () => {
it('works as expected', () => {
const buttons = [
m('button', { className: 'button-1' }, 'Option A'),
m('button', { className: 'button-2' }, 'Option B'),
m('button', { className: 'button-3' }, 'Option C'),
];
const select = mq(
m(
SplitDropdown,
{
label: 'Select the option',
},
buttons
)
);
expect(select).not.toContainRaw('Select an option');
expect(select).toContainRaw('Option A');
expect(select).toContainRaw('Option B');
expect(select).toContainRaw('Option C');
// First button is displayed as its own button separate from the dropdown
expect(select).toHaveElement('.SplitDropdown-button.button-1');
expect(select).toHaveElement('li .button-1');
expect(select).not.toHaveElement('.SplitDropdown-button.button-2');
expect(select).toHaveElement('li .button-2');
expect(select).not.toHaveElement('.SplitDropdown-button.button-3');
expect(select).toHaveElement('li .button-3');
});
});

View File

@ -0,0 +1,102 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Model from '../../../../src/common/extenders/Model';
import Discussion from '../../../../src/common/models/Discussion';
import User from '../../../../src/common/models/User';
import app from '@flarum/core/src/forum/app';
beforeAll(() => bootstrapForum());
describe('Model extender', () => {
const discussion = new Discussion({
id: '1',
type: 'discussions',
attributes: {
title: 'Discussion title',
keanu: 'Reeves',
},
relationships: {
posts: {
data: [
{ id: '1', type: 'posts' },
{ id: '2', type: 'posts' },
{ id: '3', type: 'posts' },
{ id: '4', type: 'posts' },
],
},
idrisElba: {
data: { id: '1', type: 'users' },
},
people: {
data: [{ id: '1', type: 'users' }],
},
},
});
test('new attribute does not work if not defined', () => {
app.boot();
// @ts-ignore
expect(() => discussion.keanu()).toThrow();
});
test('added route works', () => {
app.bootExtensions({
test: {
extend: [new Model(Discussion).attribute<string>('keanu')],
},
});
app.boot();
// @ts-ignore
expect(() => discussion.keanu()).not.toThrow();
// @ts-ignore
expect(discussion.keanu()).toBe('Reeves');
});
test('to one relationship does not work if not defined', () => {
app.boot();
// @ts-ignore
expect(() => discussion.idrisElba()).toThrow();
});
test('added to one relationship works', () => {
app.bootExtensions({
test: {
extend: [new Model(Discussion).hasOne<User>('idrisElba')],
},
});
app.boot();
// @ts-ignore
expect(() => discussion.idrisElba()).not.toThrow();
// @ts-ignore
expect(discussion.idrisElba()).toBeInstanceOf(User);
});
test('to many relationship does not work if not defined', () => {
app.boot();
// @ts-ignore
expect(() => discussion.people()).toThrow();
});
test('added to many relationship works', () => {
app.bootExtensions({
test: {
extend: [new Model(Discussion).hasMany<User>('people')],
},
});
app.boot();
// @ts-ignore
expect(() => discussion.people()).not.toThrow();
// @ts-ignore
expect(discussion.people()).toBeInstanceOf(Array);
// @ts-ignore
expect(discussion.people()[0]).toBeInstanceOf(User);
});
});

View File

@ -0,0 +1,100 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Extend from '../../../../src/common/extenders';
import NotificationType from '../../../../src/forum/components/NotificationType';
import NotificationComponent from '../../../../src/forum/components/Notification';
import Notification from '../../../../src/common/models/Notification';
import mq from 'mithril-query';
import app from '@flarum/core/src/forum/app';
import Mithril from 'mithril';
beforeAll(() => bootstrapForum());
describe('Notification extender', () => {
app.store.pushPayload({
data: {
id: '1',
type: 'discussions',
attributes: {
title: 'Discussion title',
},
relationships: {
posts: {
data: [
{ id: '1', type: 'posts' },
{ id: '2', type: 'posts' },
{ id: '3', type: 'posts' },
{ id: '4', type: 'posts' },
],
},
},
},
});
const keanuNotification = new Notification({
id: '1',
type: 'notifications',
attributes: { contentType: 'keanu', canEdit: false, createdAt: new Date() },
relationships: { subject: { data: { type: 'discussions', id: '1' } } },
});
const normalNotification = new Notification({
id: '2',
type: 'notifications',
attributes: { contentType: 'discussionRenamed', canEdit: false, createdAt: new Date(), content: { postNumber: 1 } },
relationships: { subject: { data: { type: 'discussions', id: '1' } } },
});
it('existing notifications work by default', () => {
app.boot();
const notificationComponent = mq(NotificationType, {
notification: normalNotification,
});
expect(notificationComponent).toContainRaw('changed the title');
});
it('does not work before registering the notification type', () => {
app.boot();
const notificationComponent = mq(NotificationType, {
notification: keanuNotification,
});
expect(notificationComponent).not.toContainRaw('Keanu Reeves is awesome');
});
it('works after registering the notification type', () => {
app.bootExtensions({
test: {
extend: [new Extend.Notification().add('keanu', KeanuNotification)],
},
});
app.boot();
const notificationComponent = mq(NotificationType, {
notification: keanuNotification,
});
expect(notificationComponent).toContainRaw('Keanu Reeves is awesome');
});
});
class KeanuNotification extends NotificationComponent {
icon() {
return 'fas fa-keanu';
}
content(): Mithril.Children {
return 'Keanu Reeves is awesome';
}
excerpt(): Mithril.Children {
return null;
}
href(): string {
return '';
}
}

View File

@ -0,0 +1,91 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import PostTypes from '../../../../src/common/extenders/PostTypes';
import PostType from '../../../../src/forum/components/PostType';
import EventPost from '../../../../src/forum/components/EventPost';
import Post from '../../../../src/common/models/Post';
import mq from 'mithril-query';
import app from '@flarum/core/src/forum/app';
beforeAll(() => bootstrapForum());
describe('PostTypes extender', () => {
app.store.pushPayload({
data: {
id: '1',
type: 'discussions',
attributes: {
title: 'Discussion title',
},
relationships: {
posts: {
data: [
{ id: '1', type: 'posts' },
{ id: '2', type: 'posts' },
{ id: '3', type: 'posts' },
{ id: '4', type: 'posts' },
],
},
},
},
});
const keanuPost = new Post({
id: '1',
type: 'posts',
attributes: { contentType: 'keanu', canEdit: false, createdAt: new Date(), contentHtml: '<strong>Hi</strong>' },
relationships: { discussion: { data: { type: 'discussions', id: '1' } } },
});
const commentPost = new Post({
id: '2',
type: 'posts',
attributes: { contentType: 'comment', canEdit: false, createdAt: new Date(), contentHtml: '<strong>Bye</strong>' },
relationships: { discussion: { data: { type: 'discussions', id: '1' } } },
});
it('comment posts work by default', () => {
app.boot();
const postComponent = mq(PostType, {
post: commentPost,
});
expect(postComponent).toContainRaw('Bye');
});
it('does not work before registering the post type', () => {
app.boot();
const postComponent = mq(PostType, {
post: keanuPost,
});
expect(postComponent).not.toContainRaw('Hi');
});
it('works after registering the post type', () => {
app.bootExtensions({
test: {
extend: [new PostTypes().add('keanu', KeanuPost)],
},
});
app.boot();
const postComponent = mq(PostType, {
post: keanuPost,
});
expect(postComponent).toContainRaw('Hi');
});
});
class KeanuPost extends EventPost {
icon() {
return 'fas fa-keanu';
}
description() {
return this.attrs.post.contentHtml();
}
}

View File

@ -0,0 +1,41 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Routes from '../../../../src/common/extenders/Routes';
import app from '@flarum/core/src/forum/app';
beforeAll(() => bootstrapForum());
describe('Routes extender', () => {
test('non added route does not work', () => {
app.boot();
expect(() => app.route('nonexistent')).toThrow();
});
test('added route works', () => {
app.bootExtensions({
test: {
extend: [new Routes().add('nonexistent', '/nonexistent', null)],
},
});
app.boot();
expect(() => app.route('nonexistent')).not.toThrow();
expect(app.route('nonexistent')).toBe('/nonexistent');
});
test('added route helper works', () => {
app.bootExtensions({
test: {
extend: [new Routes().helper('nonexistent', () => '/nonexistent')],
},
});
app.boot();
// @ts-ignore
expect(() => app.route.nonexistent()).not.toThrow();
// @ts-ignore
expect(app.route.nonexistent()).toBe('/nonexistent');
});
});

View File

@ -0,0 +1,45 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Search from '../../../../src/common/extenders/Search';
import { KeyValueGambit } from '../../../../src/common/query/IGambit';
import { app } from '../../../../src/forum';
beforeAll(() => bootstrapForum());
describe('Search extender', () => {
it('gambit does not work before registering it', () => {
app.boot();
expect(app.search.gambits.apply('discussions', { q: 'lorem keanu:reeves' })).toStrictEqual({
q: 'lorem keanu:reeves',
});
});
it('works after registering it', () => {
app.bootExtensions({
test: {
extend: [new Search().gambit('discussions', KeanuGambit)],
},
});
app.boot();
expect(app.search.gambits.apply('discussions', { q: 'lorem keanu:reeves' })).toStrictEqual({
q: 'lorem',
keanu: 'reeves',
});
});
});
class KeanuGambit extends KeyValueGambit {
filterKey(): string {
return 'keanu';
}
hint(): string {
return 'Keanu is breathtaking';
}
key(): string {
return 'keanu';
}
}

View File

@ -0,0 +1,67 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import Extend from '../../../../src/common/extenders';
import Discussion from '../../../../src/common/models/Discussion';
import Model from '../../../../src/common/Model';
import app from '@flarum/core/src/forum/app';
beforeAll(() => bootstrapForum());
describe('Store extender', () => {
const discussion = new Discussion({
id: '1',
type: 'discussions',
attributes: {
title: 'Discussion title',
},
relationships: {
posts: {
data: [
{ id: '1', type: 'posts' },
{ id: '2', type: 'posts' },
{ id: '3', type: 'posts' },
{ id: '4', type: 'posts' },
],
},
potato: {
data: { id: '1', type: 'potatoes' },
},
},
});
const pushPotato = () =>
app.store.pushPayload({
data: {
type: 'potatoes',
id: '1',
attributes: {},
},
});
test('new model type does not work if not defined', () => {
app.boot();
expect(pushPotato).toThrow();
// @ts-ignore
expect(() => discussion.potato()).toThrow();
});
test('added route works', () => {
app.bootExtensions({
test: {
extend: [new Extend.Store().add('potatoes', Potato), new Extend.Model(Discussion).hasOne<Potato>('potato')],
},
});
pushPotato();
app.boot();
// @ts-ignore
expect(() => discussion.potato()).not.toThrow();
// @ts-ignore
expect(discussion.potato()).toBeInstanceOf(Potato);
});
});
class Potato extends Model {}

View File

@ -0,0 +1,16 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import HeaderSecondary from '../../../../src/forum/components/HeaderSecondary';
import { app } from '../../../../src/forum';
import mq from 'mithril-query';
beforeAll(() => bootstrapForum());
describe('HeaderSecondary', () => {
beforeAll(() => app.boot());
test('renders', () => {
const header = mq(HeaderSecondary);
expect(header).toBeTruthy();
});
});

View File

@ -0,0 +1,100 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import IndexPage from '../../../../src/forum/components/IndexPage';
import { app } from '../../../../src/forum';
import mq from 'mithril-query';
import Discussion from '../../../../src/common/models/Discussion';
import { makeDiscussion } from '../../../factory';
beforeAll(() => bootstrapForum());
describe('IndexPage', () => {
// mock app.store.find
// @ts-ignore
app.store.find = function () {
return Promise.resolve([
new Discussion(
makeDiscussion({
id: '1',
attributes: {
title: 'Discussion 1',
slug: 'discussion-1',
},
relationships: {
firstPost: {
data: { type: 'posts', id: '2' },
},
},
})
),
new Discussion(
makeDiscussion({
id: '2',
attributes: {
title: 'Discussion 2',
slug: 'discussion-2',
},
relationships: {
firstPost: {
data: { type: 'posts', id: '2' },
},
},
})
),
]);
};
beforeAll(() => {
app.boot();
app.store.pushPayload({
data: [
{
type: 'posts',
id: '1',
attributes: {
content: 'Post 1',
number: 1,
},
relationships: {
discussion: {
data: { type: 'discussions', id: '1' },
},
user: {
data: { type: 'users', id: '1' },
},
},
},
{
type: 'posts',
id: '2',
attributes: {
content: 'Post 2',
number: 1,
},
relationships: {
discussion: {
data: { type: 'discussions', id: '2' },
},
user: {
data: { type: 'users', id: '1' },
},
},
},
],
});
});
test('renders', () => {
const page = mq(IndexPage, {});
// wait a tick for the store.find promise to resolve
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
page.redraw();
expect(page).toHaveElement('.IndexPage');
expect(page).toHaveElement('.DiscussionList');
expect(page).toHaveElement('.DiscussionListItem');
expect(page).toContainRaw('Discussion 1');
expect(page).toContainRaw('Discussion 2');
});
});
});

View File

@ -0,0 +1,108 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import mq from 'mithril-query';
import { app } from '../../../../src/forum';
import ModalManager from '../../../../src/common/components/ModalManager';
import DiscussionsSearchSource from '../../../../src/forum/components/DiscussionsSearchSource';
import ChangeEmailModal from '../../../../src/forum/components/ChangeEmailModal';
import ChangePasswordModal from '../../../../src/forum/components/ChangePasswordModal';
import ForgotPasswordModal from '../../../../src/forum/components/ForgotPasswordModal';
import LogInModal from '../../../../src/forum/components/LogInModal';
import NewAccessTokenModal from '../../../../src/forum/components/NewAccessTokenModal';
import RenameDiscussionModal from '../../../../src/forum/components/RenameDiscussionModal';
import SearchModal from '../../../../src/common/components/SearchModal';
import SignUpModal from '../../../../src/forum/components/SignUpModal';
beforeAll(() => bootstrapForum());
describe('Modals', () => {
beforeAll(() => app.boot());
beforeEach(() => app.modal.close());
test('ChangeEmailModal renders', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(ChangeEmailModal);
manager.redraw();
expect(app.modal.modalList.length).toEqual(1);
expect(manager).toHaveElement('.ModalManager');
});
test('ChangePasswordModal renders', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(ChangePasswordModal);
manager.redraw();
expect(app.modal.modalList.length).toEqual(1);
expect(manager).toHaveElement('.ModalManager');
});
test('ForgotPasswordModal renders', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(ForgotPasswordModal);
manager.redraw();
expect(app.modal.modalList.length).toEqual(1);
expect(manager).toHaveElement('.ModalManager');
});
test('LogInModal renders', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(LogInModal);
manager.redraw();
expect(app.modal.modalList.length).toEqual(1);
expect(manager).toHaveElement('.ModalManager');
});
test('NewAccessTokenModal renders', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(NewAccessTokenModal);
manager.redraw();
expect(app.modal.modalList.length).toEqual(1);
expect(manager).toHaveElement('.ModalManager');
});
test('RenameDiscussionModal renders', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(RenameDiscussionModal);
manager.redraw();
expect(app.modal.modalList.length).toEqual(1);
expect(manager).toHaveElement('.ModalManager');
});
test('SearchModal renders', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(SearchModal, { searchState: app.search.state, sources: [new DiscussionsSearchSource()] });
manager.redraw();
expect(app.modal.modalList.length).toEqual(1);
expect(manager).toHaveElement('.ModalManager');
});
test('SignUpModal renders', () => {
const manager = mq(ModalManager, { state: app.modal });
app.modal.show(SignUpModal);
manager.redraw();
expect(app.modal.modalList.length).toEqual(1);
expect(manager).toHaveElement('.ModalManager');
});
});

View File

@ -0,0 +1,90 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import PostStream from '../../../../src/forum/components/PostStream';
import PostStreamState from '../../../../src/forum/states/PostStreamState';
import Discussion from '../../../../src/common/models/Discussion';
import app from '../../../../src/forum/app';
import mq from 'mithril-query';
beforeAll(() => bootstrapForum());
describe('PostStream component', () => {
app.store.pushPayload({
data: {
id: '1',
type: 'discussions',
attributes: {
title: 'Discussion title',
},
relationships: {
posts: {
data: [
{ id: '1', type: 'posts' },
{ id: '2', type: 'posts' },
{ id: '3', type: 'posts' },
{ id: '4', type: 'posts' },
],
},
},
},
});
app.store.pushPayload({
data: [
{
id: '1',
type: 'posts',
attributes: { contentType: 'comment', canEdit: false, createdAt: new Date(), contentHtml: '<strong>Hi</strong>' },
relationships: { discussion: { data: { type: 'discussions', id: '1' } } },
},
{
id: '2',
type: 'posts',
attributes: {
contentType: 'comment',
canEdit: false,
createdAt: new Date(),
contentHtml: '<strong>Bye</strong>',
},
relationships: { discussion: { data: { type: 'discussions', id: '1' } } },
},
{
id: '3',
type: 'posts',
attributes: {
contentType: 'comment',
canEdit: false,
createdAt: new Date(),
contentHtml: '<strong>Hi again</strong>',
},
relationships: { discussion: { data: { type: 'discussions', id: '1' } } },
},
{
id: '4',
type: 'posts',
attributes: {
contentType: 'comment',
canEdit: false,
createdAt: new Date(),
contentHtml: '<strong>Bye again</strong>',
},
relationships: { discussion: { data: { type: 'discussions', id: '1' } } },
},
],
});
const discussion = app.store.getById<Discussion>('discussions', '1');
it('renders correctly', () => {
app.boot();
const postStream = mq(PostStream, {
stream: new PostStreamState(discussion, app.store.all('posts')),
discussion,
});
expect(postStream).toContainRaw('Hi');
expect(postStream).toContainRaw('Bye');
expect(postStream).toContainRaw('Hi again');
expect(postStream).toContainRaw('Bye again');
});
});

View File

@ -1,34 +0,0 @@
import GambitManager from '../../../src/common/GambitManager';
const gambits = new GambitManager();
test('gambits are converted to filters', function () {
expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07 is:hidden author:behz' })).toStrictEqual({
q: 'lorem',
created: '2023-07-07',
hidden: true,
author: 'behz',
});
});
test('gambits are negated when prefixed with a dash', function () {
expect(gambits.apply('discussions', { q: 'lorem -created:2023-07-07 -is:hidden -author:behz' })).toStrictEqual({
q: 'lorem',
'-created': '2023-07-07',
'-hidden': true,
'-author': 'behz',
});
});
test('gambits are only applied for the correct resource type', function () {
expect(gambits.apply('users', { q: 'lorem created:2023-07-07 is:hidden author:behz email:behz@machine.local' })).toStrictEqual({
q: 'lorem created:2023-07-07 is:hidden author:behz',
email: 'behz@machine.local',
});
expect(gambits.apply('discussions', { q: 'lorem created:2023-07-07..2023-10-18 is:hidden -author:behz email:behz@machine.local' })).toStrictEqual({
q: 'lorem email:behz@machine.local',
created: '2023-07-07..2023-10-18',
hidden: true,
'-author': 'behz',
});
});

View File

@ -0,0 +1,129 @@
import { extend, override } from '../../../src/common/extend';
import Component from '../../../src/common/Component';
import Mithril from 'mithril';
import m from 'mithril';
import mq from 'mithril-query';
describe('extend', () => {
test('can extend component methods', () => {
extend(Acme.prototype, 'view', function (original) {
original.children.push(m('p', 'This is a test extension.'));
});
const acme = mq(Acme);
expect(acme).toContainRaw('This is a test extension.');
});
test('can extend multiple methods at once', () => {
let counter = 0;
extend(Acme.prototype, ['items', 'otherItems'], function (original) {
const dataCount = counter++;
original.children.push(m('li', { 'data-count': dataCount }, 'Breaking News!'));
});
const acme = mq(Acme);
expect(acme).toContainRaw('Breaking News!');
expect(acme).toHaveElement('li[data-count="0"]');
expect(acme).toHaveElement('li[data-count="1"]');
});
test('can extend lazy loaded components', () => {
extend('flarum/common/components/Lazy', 'view', function (original) {
original.children.push(m('div', 'Loaded the lazy component.'));
});
const lazy = mq(Lazy);
expect(lazy).toContainRaw('Lazy loaded component.');
expect(lazy).not.toContainRaw('Loaded the lazy component.');
// Emulate the lazy loading of the component.
// @ts-ignore
flarum.reg.add('core', 'common/components/Lazy', Lazy);
const lazy2 = mq(Lazy);
expect(lazy2).toContainRaw('Lazy loaded component.');
expect(lazy2).toContainRaw('Loaded the lazy component.');
});
});
describe('override', () => {
test('can override component methods', () => {
override(Acme.prototype, 'items', function (original) {
return m('ul', [m('li', 'ItemOverride 1'), m('li', 'ItemOverride 2'), m('li', 'ItemOverride 3')]);
});
const acme = mq(Acme);
expect(acme).toContainRaw('ItemOverride 1');
expect(acme).toContainRaw('ItemOverride 2');
expect(acme).toContainRaw('ItemOverride 3');
expect(acme).not.toContainRaw('Item 1');
expect(acme).not.toContainRaw('Item 2');
expect(acme).not.toContainRaw('Item 3');
});
test('can override multiple methods at once', () => {
override(Acme.prototype, ['items', 'otherItems'], function (original) {
return m('ul', [m('li', 'ItemOverride 1'), m('li', 'ItemOverride 2'), m('li', 'ItemOverride 3')]);
});
const acme = mq(Acme);
expect(acme).toContainRaw('ItemOverride 1');
expect(acme).toContainRaw('ItemOverride 2');
expect(acme).toContainRaw('ItemOverride 3');
expect(acme).not.toContainRaw('Item 1');
expect(acme).not.toContainRaw('Item 2');
expect(acme).not.toContainRaw('Item 3');
expect(acme).not.toContainRaw('ItemOther 1');
expect(acme).not.toContainRaw('ItemOther 2');
expect(acme).not.toContainRaw('ItemOther 3');
});
test('can override lazy loaded components', () => {
override('flarum/common/components/Lazy', 'view', function (original) {
return m('div', 'Overridden lazy component.');
});
const lazy = mq(Lazy);
expect(lazy).toContainRaw('Lazy loaded component.');
expect(lazy).not.toContainRaw('Overridden lazy component.');
// Emulate the lazy loading of the component.
// @ts-ignore
flarum.reg.add('core', 'common/components/Lazy', Lazy);
const lazy2 = mq(Lazy);
expect(lazy2).not.toContainRaw('Lazy loaded component.');
expect(lazy2).toContainRaw('Overridden lazy component.');
});
});
class Acme extends Component {
view(): Mithril.Children {
return m('div', { class: 'Acme' }, [m('h1', m('div', this.items())), m('p', 'This is a test component.'), m('div', this.otherItems())]);
}
items() {
return m('ul', [m('li', 'Item 1'), m('li', 'Item 2'), m('li', 'Item 3')]);
}
otherItems() {
return m('ul', [m('li', 'ItemOther 1'), m('li', 'ItemOther 2'), m('li', 'ItemOther 3')]);
}
}
class Lazy extends Component {
view(): Mithril.Children {
return m('div', 'Lazy loaded component.');
}
}

View File

@ -0,0 +1,24 @@
import highlight from '../../../../src/common/helpers/highlight';
describe('highlight', () => {
it('should return the string if no phrase or length is given', () => {
const string = 'Hello, world!';
expect(highlight(string)).toBe(string);
});
it('should highlight a phrase in a string', () => {
const string = 'Hello, world!';
const phrase = 'world';
// @ts-ignore
expect(highlight(string, phrase).children).toBe('Hello, <mark>world</mark>!');
});
it('should highlight a phrase in a string case-insensitively', () => {
const string = 'Hello, world!';
const phrase = 'WORLD';
// @ts-ignore
expect(highlight(string, phrase).children).toBe('Hello, <mark>world</mark>!');
});
});

View File

@ -0,0 +1,42 @@
import listItems from '../../../../src/common/helpers/listItems';
import m from 'mithril';
describe('listItems', () => {
it('should return an array of Vnodes', () => {
const items = [m('div', 'Item 1'), m('div', 'Item 2'), m('div', 'Item 3')];
const result = listItems(items);
expect(result).toHaveLength(3);
expect(result[0].tag).toBe('li');
expect(result[0].children[0].children[0].children).toBe('Item 1');
expect(result[1].children[0].children[0].children).toBe('Item 2');
expect(result[2].children[0].children[0].children).toBe('Item 3');
});
it('should return an array of Vnodes with custom tag', () => {
const items = [m('div', 'Item 1'), m('div', 'Item 2'), m('div', 'Item 3')];
const result = listItems(items, 'customTag');
expect(result).toHaveLength(3);
expect(result[0].tag).toBe('customTag');
expect(result[0].children[0].children[0].children).toBe('Item 1');
expect(result[1].children[0].children[0].children).toBe('Item 2');
expect(result[2].children[0].children[0].children).toBe('Item 3');
});
it('should return an array of Vnodes with custom tag and attributes', () => {
const items = [m('div', 'Item 1'), m('div', 'Item 2'), m('div', 'Item 3')];
const result = listItems(items, 'ul', { id: 'list' });
expect(result).toHaveLength(3);
expect(result[0].tag).toBe('ul');
// @ts-ignore
expect(result[0].attrs.id).toBe('list');
expect(result[0].children[0].children[0].children).toBe('Item 1');
expect(result[1].children[0].children[0].children).toBe('Item 2');
expect(result[2].children[0].children[0].children).toBe('Item 3');
});
});

View File

@ -1,5 +1,8 @@
import bootstrapForum from '@flarum/jest-config/src/boostrap/forum';
import abbreviateNumber from '../../../../src/common/utils/abbreviateNumber'; import abbreviateNumber from '../../../../src/common/utils/abbreviateNumber';
beforeAll(() => bootstrapForum());
test('does not change small numbers', () => { test('does not change small numbers', () => {
expect(abbreviateNumber(1)).toBe('1'); expect(abbreviateNumber(1)).toBe('1');
}); });

View File

@ -0,0 +1,20 @@
import extractText from '../../../../src/common/utils/extractText';
describe('extractText', () => {
it('should extract text from a virtual element', () => {
const vdom = ['Hello, ', { tag: 'span', children: 'world' }, '!'];
// @ts-ignore
expect(extractText(vdom)).toBe('Hello, world!');
});
it('should extract text from a nested virtual element', () => {
const vdom = ['Hello, ', { tag: 'span', children: ['world', '!'] }];
// @ts-ignore
expect(extractText(vdom)).toBe('Hello, world!');
});
it('should extract text from an array of strings', () => {
const vdom = ['Hello, ', 'world', '!'];
expect(extractText(vdom)).toBe('Hello, world!');
});
});

View File

@ -0,0 +1,28 @@
import * as string from '../../../../src/common/utils/string';
describe('string', () => {
it('should slugify a string', () => {
const stringToSlugify = 'Hello, world!';
expect(string.slug(stringToSlugify)).toBe('hello-world');
});
it('should slugify a string with a custom slugging mode', () => {
const stringToSlugify = 'Hello, world!';
expect(string.slug(stringToSlugify, string.SluggingMode.UTF8)).toBe('hello-world');
});
it('should slugify a string with a custom slugging mode', () => {
const stringToSlugify = 'Hello, world!';
expect(string.slug(stringToSlugify, string.SluggingMode.ALPHANUMERIC)).toBe('hello-world');
});
it('should make the first character of a string uppercase', () => {
const stringToUppercase = 'hello, world!';
expect(string.ucfirst(stringToUppercase)).toBe('Hello, world!');
});
it('should transform a camel case string to snake case', () => {
const stringToTransform = 'helloWorld';
expect(string.camelCaseToSnakeCase(stringToTransform)).toBe('hello_world');
});
});

View File

@ -1,5 +1,10 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"include": ["tests/**/*"], "include": ["tests/**/*"],
"files": ["../../../node_modules/@flarum/jest-config/shims.d.ts"] "files": ["../../../node_modules/@flarum/jest-config/shims.d.ts"],
"compilerOptions": {
"strict": false,
"noImplicitReturns": false,
"noImplicitAny": false
}
} }

View File

@ -4,15 +4,18 @@ This package provides a [Jest](https://jestjs.io/) config object to run unit & i
## Usage ## Usage
* Install the package: `yarn add --dev @flarum/jest-config` - Install the package: `yarn add --dev @flarum/jest-config`
* Add `"type": "module"` to your `package.json` - Add `"type": "module"` to your `package.json`
* Add `"test": "yarn node --experimental-vm-modules $(yarn bin jest)"` to your `package.json` scripts - Add `"test": "yarn node --experimental-vm-modules $(yarn bin jest)"` to your `package.json` scripts
* Rename `webpack.config.js` to `webpack.config.cjs` - Rename `webpack.config.js` to `webpack.config.cjs`
* Create a `jest.config.cjs` file with the following content: - Create a `jest.config.cjs` file with the following content:
```js ```js
module.exports = require('@flarum/jest-config')(); module.exports = require('@flarum/jest-config')();
``` ```
* If you are using TypeScript, create `tsconfig.test.json` with the following content:
- If you are using TypeScript, create `tsconfig.test.json` with the following content:
```json ```json
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",

View File

@ -4,10 +4,7 @@ module.exports = (options = {}) => ({
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
extensionsToTreatAsEsm: ['.ts', '.tsx'], extensionsToTreatAsEsm: ['.ts', '.tsx'],
transform: { transform: {
'^.+\\.[tj]sx?$': [ '^.+\\.[tj]sx?$': ['babel-jest', require('flarum-webpack-config/babel.config.cjs')],
'babel-jest',
require('flarum-webpack-config/babel.config.cjs'),
],
'^.+\\.tsx?$': [ '^.+\\.tsx?$': [
'ts-jest', 'ts-jest',
{ {
@ -16,6 +13,7 @@ module.exports = (options = {}) => ({
], ],
}, },
preset: 'ts-jest', preset: 'ts-jest',
setupFiles: [path.resolve(__dirname, 'pollyfills.js')],
setupFilesAfterEnv: [path.resolve(__dirname, 'setup-env.js')], setupFilesAfterEnv: [path.resolve(__dirname, 'setup-env.js')],
moduleDirectories: ['node_modules', 'src'], moduleDirectories: ['node_modules', 'src'],
...options, ...options,

View File

@ -14,6 +14,7 @@
"jest": "^29.3.1", "jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1", "jest-environment-jsdom": "^29.3.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsdom": "^24.0.0",
"mithril-query": "^4.0.1", "mithril-query": "^4.0.1",
"ts-jest": "^29.0.3" "ts-jest": "^29.0.3"
}, },

View File

@ -0,0 +1,3 @@
import { TextEncoder, TextDecoder } from 'util';
Object.assign(global, { TextDecoder, TextEncoder });

View File

@ -1,54 +1,61 @@
import app from '@flarum/core/src/forum/app'; import mixin from '@flarum/core/src/common/utils/mixin';
import ForumApplication from '@flarum/core/src/forum/ForumApplication'; import ExportRegistry from '@flarum/core/src/common/ExportRegistry';
import jsYaml from 'js-yaml';
import fs from 'fs';
import jquery from 'jquery'; import jquery from 'jquery';
import m from 'mithril'; import m from 'mithril';
import flatten from 'flat'; import dayjs from 'dayjs';
import './test-matchers'; import './test-matchers';
// Boot the Flarum app. import relativeTime from 'dayjs/plugin/relativeTime';
function bootApp() { import localizedFormat from 'dayjs/plugin/localizedFormat';
ForumApplication.prototype.mount = () => {}; import jsdom from 'jsdom';
window.flarum = { extensions: {} };
app.load({
apiDocument: null,
locale: 'en',
locales: {},
resources: [
{
type: 'forums',
id: '1',
attributes: {
canEditUserCredentials: true,
},
},
{
type: 'users',
id: '1',
attributes: {
id: 1,
username: 'admin',
displayName: 'Admin',
email: 'admin@machine.local',
joinTime: '2021-01-01T00:00:00Z',
isEmailConfirmed: true,
},
},
],
session: {
userId: 1,
csrfToken: 'test',
},
});
app.translator.addTranslations(flatten(jsYaml.load(fs.readFileSync('../locale/core.yml', 'utf8'))));
app.bootExtensions(window.flarum.extensions);
app.boot();
}
beforeAll(() => { dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
process.env.testing = true;
const dom = new jsdom.JSDOM('', {
pretendToBeVisual: false,
});
// Fill in the globals Mithril.js needs to operate. Also, the first two are often
// useful to have just in tests.
global.window = dom.window;
global.document = dom.window.document;
global.requestAnimationFrame = (callback) => callback();
// Some other needed pollyfills.
window.$ = jquery; window.$ = jquery;
window.m = m; window.m = m;
window.$.fn.tooltip = () => {};
bootApp(); window.matchMedia = () => ({
addListener: () => {},
removeListener: () => {},
});
window.scrollTo = () => {};
// Flarum specific globals.
global.flarum = {
extensions: {},
reg: new (mixin(ExportRegistry, {
checkModule: () => true,
}))(),
};
// Prepare basic dom structure.
document.body.innerHTML = `
<div id="app">
<main class="App-content">
<div id="notices"></div>
<div id="content"></div>
</main>
</div>
`;
beforeEach(() => {
flarum.reg.clear();
});
afterAll(() => {
dom.window.close();
}); });

View File

@ -4,6 +4,8 @@ declare global {
namespace jest { namespace jest {
interface Matchers<R> { interface Matchers<R> {
toHaveElement(selector: any): R; toHaveElement(selector: any): R;
toHaveElementAttr(selector: any, attribute: any, value: any): R;
toHaveElementAttr(selector: any, attribute: any): R;
toContainRaw(content: any): R; toContainRaw(content: any): R;
} }
} }

View File

@ -0,0 +1,18 @@
import app from '@flarum/core/src/admin/app';
import AdminApplication from '@flarum/core/src/admin/AdminApplication';
import bootstrap from './common.js';
export default function bootstrapAdmin(payload = {}) {
return bootstrap(AdminApplication, app, {
extensions: {},
settings: {},
permissions: {},
displayNameDrivers: [],
slugDrivers: {},
searchDrivers: {},
modelStatistics: {
users: 1,
},
...payload,
});
}

View File

@ -0,0 +1,41 @@
import Drawer from '@flarum/core/src/common/utils/Drawer';
import { makeUser } from '@flarum/core/tests/factory';
import flatten from 'flat';
import jsYaml from 'js-yaml';
import fs from 'fs';
export default function bootstrap(Application, app, payload = {}) {
Application.prototype.mount = () => {};
app.load({
apiDocument: null,
locale: 'en',
locales: {},
resources: [
{
type: 'forums',
id: '1',
attributes: {
canEditUserCredentials: true,
},
},
makeUser({
id: '1',
attributes: {
id: 1,
username: 'admin',
displayName: 'Admin',
email: 'admin@machine.local',
},
}),
],
session: {
userId: 1,
csrfToken: 'test',
},
...payload,
});
app.translator.addTranslations(flatten(jsYaml.load(fs.readFileSync('../locale/core.yml', 'utf8'))));
app.drawer = new Drawer();
}

View File

@ -0,0 +1,7 @@
import app from '@flarum/core/src/forum/app';
import ForumApplication from '@flarum/core/src/forum/ForumApplication';
import bootstrap from './common.js';
export default function bootstrapForum(payload = {}) {
return bootstrap(ForumApplication, app, payload);
}

View File

@ -4,6 +4,19 @@ import { expect } from '@jest/globals';
expect.extend({ expect.extend({
toHaveElement: intoMatcher((out: any, expected: any) => out.should.have(expected), 'Expected $received to have node $expected'), toHaveElement: intoMatcher((out: any, expected: any) => out.should.have(expected), 'Expected $received to have node $expected'),
toContainRaw: intoMatcher((out: any, expected: any) => out.should.contain(expected), 'Expected $received to contain $expected'), toContainRaw: intoMatcher((out: any, expected: any) => out.should.contain(expected), 'Expected $received to contain $expected'),
toHaveElementAttr: intoMatcher(function (out: any, selector: string, attribute: string, value: string | undefined) {
out.should.have(selector);
const node = out.find(selector)[0];
const attr = node[attribute] ?? node._attrsByQName[attribute]?.data ?? undefined;
const onlyTwoArgs = value === undefined;
if (!node || (!onlyTwoArgs && attr !== value) || (onlyTwoArgs && !attr)) {
throw new Error(`Expected ${selector} to have attribute ${attribute} with value ${value}, but found ${node[attribute]}`);
}
}, 'Expected $received to have attribute $expected with value $value'),
}); });
function intoMatcher(callback: Function, message: string) { function intoMatcher(callback: Function, message: string) {

View File

@ -1,3 +1,3 @@
{ {
"extends": "flarum-tsconfig", "extends": "flarum-tsconfig"
} }

View File

@ -20,7 +20,7 @@ yarn add -D @flarum/prettier-config
{ {
"name": "my-cool-package", "name": "my-cool-package",
"version": "1.0.0", "version": "1.0.0",
"prettier": "@flarum/prettier-config", "prettier": "@flarum/prettier-config"
// ... // ...
} }
``` ```

View File

@ -4,4 +4,3 @@
"tabWidth": 2, "tabWidth": 2,
"trailingComma": "es5" "trailingComma": "es5"
} }

View File

@ -16,7 +16,16 @@
"target": "es6", "target": "es6",
"jsx": "preserve", "jsx": "preserve",
"allowJs": true, "allowJs": true,
"lib": ["dom", "es5", "es2015", "es2016", "es2017", "es2018", "es2019.array", "es2020"], "lib": [
"dom",
"es5",
"es2015",
"es2016",
"es2017",
"es2018",
"es2019.array",
"es2020"
],
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
} }
} }

View File

@ -11,11 +11,13 @@ class OverrideChunkLoaderFunction {
// The function is called by webpack so we can't just override it. // The function is called by webpack so we can't just override it.
compiler.hooks.compilation.tap('OverrideChunkLoaderFunction', (compilation) => { compiler.hooks.compilation.tap('OverrideChunkLoaderFunction', (compilation) => {
compilation.mainTemplate.hooks.requireEnsure.tap('OverrideChunkLoaderFunction', (source) => { compilation.mainTemplate.hooks.requireEnsure.tap('OverrideChunkLoaderFunction', (source) => {
return source + '\nconst originalLoadChunk = __webpack_require__.l;\n__webpack_require__.l = flarum.reg.loadChunk.bind(flarum.reg, originalLoadChunk);'; return (
source +
'\nconst originalLoadChunk = __webpack_require__.l;\n__webpack_require__.l = flarum.reg.loadChunk.bind(flarum.reg, originalLoadChunk);'
);
}); });
}); });
} }
} }
module.exports = OverrideChunkLoaderFunction; module.exports = OverrideChunkLoaderFunction;

View File

@ -7,7 +7,7 @@ class RegisterAsyncChunksPlugin {
static registry = {}; static registry = {};
processUrlPath(urlPath) { processUrlPath(urlPath) {
if (path.sep == "\\") { if (path.sep == '\\') {
// separator on windows is "\", this will cause escape issues when used in url path. // separator on windows is "\", this will cause escape issues when used in url path.
return urlPath.replace(/\\/g, '/'); return urlPath.replace(/\\/g, '/');
} }

View File

@ -1,7 +1,7 @@
const path = require("path"); const path = require('path');
const {getOptions} = require("loader-utils"); const { getOptions } = require('loader-utils');
const {validate} = require("schema-utils"); const { validate } = require('schema-utils');
const fs = require("fs"); const fs = require('fs');
const optionsSchema = { const optionsSchema = {
type: 'object', type: 'object',
@ -66,7 +66,7 @@ module.exports = function autoChunkNameLoader(source) {
chunkPath = absolutePathToImport.split(`src${path.sep}`)[1]; chunkPath = absolutePathToImport.split(`src${path.sep}`)[1];
} }
if (path.sep == "\\") { if (path.sep == '\\') {
// separator on windows is '\', the resolver only works with '/'. // separator on windows is '\', the resolver only works with '/'.
chunkPath = chunkPath.replace(/\\/g, '/'); chunkPath = chunkPath.replace(/\\/g, '/');
} }
@ -76,7 +76,9 @@ module.exports = function autoChunkNameLoader(source) {
webpackMode: 'lazy-once', webpackMode: 'lazy-once',
}; };
const comment = Object.entries(webpackCommentOptions).map(([key, value]) => `${key}: '${value}'`).join(', '); const comment = Object.entries(webpackCommentOptions)
.map(([key, value]) => `${key}: '${value}'`)
.join(', ');
// Return the new import statement // Return the new import statement
return `${pre}import(/* ${comment} */ '${relativePathToImport}')`; return `${pre}import(/* ${comment} */ '${relativePathToImport}')`;

View File

@ -98,7 +98,7 @@ function addAutoExports(source, pathToModule, moduleName) {
if (matches.length) { if (matches.length) {
const map = matches.reduce((map, match) => { const map = matches.reduce((map, match) => {
const names = match[1] ? match[1].split(',') : (match[2] ? [match[2]] : null); const names = match[1] ? match[1].split(',') : match[2] ? [match[2]] : null;
if (!names) { if (!names) {
return map; return map;
@ -179,7 +179,8 @@ module.exports = function autoExportLoader(source) {
// Get the path of the module to be exported // Get the path of the module to be exported
// relative to the src directory. // relative to the src directory.
// Example: src/forum/components/UserCard.js => forum/components // Example: src/forum/components/UserCard.js => forum/components
const pathToModule = path.relative(path.resolve(this.rootContext, 'src'), this.resourcePath) const pathToModule = path
.relative(path.resolve(this.rootContext, 'src'), this.resourcePath)
.replaceAll(path.sep, '/') .replaceAll(path.sep, '/')
.replace(/[A-Za-z_]+\.[A-Za-z_]+$/, ''); .replace(/[A-Za-z_]+\.[A-Za-z_]+$/, '');

View File

@ -1,3 +1 @@
module.exports = (name) => name === 'flarum/core' module.exports = (name) => (name === 'flarum/core' ? 'core' : name.replace('/flarum-ext-', '-').replace('/flarum-', '').replace('/', '-'));
? 'core'
: name.replace('/flarum-ext-', '-').replace('/flarum-', '').replace('/', '-')

View File

@ -1,8 +1,8 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { NormalModuleReplacementPlugin } = require('webpack'); const { NormalModuleReplacementPlugin } = require('webpack');
const RegisterAsyncChunksPlugin = require("./RegisterAsyncChunksPlugin.cjs"); const RegisterAsyncChunksPlugin = require('./RegisterAsyncChunksPlugin.cjs');
const OverrideChunkLoaderFunction = require("./OverrideChunkLoaderFunction.cjs"); const OverrideChunkLoaderFunction = require('./OverrideChunkLoaderFunction.cjs');
const entryPointNames = ['forum', 'admin']; const entryPointNames = ['forum', 'admin'];
const entryPointExts = ['js', 'ts']; const entryPointExts = ['js', 'ts'];
@ -106,8 +106,8 @@ module.exports = function () {
cacheGroups: { cacheGroups: {
// Avoid node_modules being split into separate chunks // Avoid node_modules being split into separate chunks
defaultVendors: false, defaultVendors: false,
} },
} },
}, },
output: { output: {

4852
yarn.lock

File diff suppressed because it is too large Load Diff