ca34d05377
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS, dry-run проходит.
192 lines
8.1 KiB
TypeScript
192 lines
8.1 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { render, screen } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
|
|
import { MessageContent } from '@/components/chat/MessageContent';
|
|
import { useSessionStore } from '@/stores/session';
|
|
import type { SessionGuild } from '@/stores/session';
|
|
|
|
afterEach(() => {
|
|
useSessionStore.getState().reset();
|
|
});
|
|
|
|
/** Разметка сообщения: React-узлы без dangerouslySetInnerHTML. */
|
|
function markup(content: string) {
|
|
return render(<MessageContent content={content} />);
|
|
}
|
|
|
|
describe('разметка: выделение и жирный курсив', () => {
|
|
it('==текст== превращается в mark', () => {
|
|
const { container } = markup('важно: ==выделение== да');
|
|
|
|
const mark = container.querySelector('mark');
|
|
expect(mark).toHaveTextContent('выделение');
|
|
expect(container.textContent).toBe('важно: выделение да');
|
|
});
|
|
|
|
it('__жирный__ работает как **жирный**', () => {
|
|
const { container } = markup('__жирный__ и **тоже**');
|
|
|
|
const strong = container.querySelectorAll('strong');
|
|
expect(strong).toHaveLength(2);
|
|
expect(strong[0]).toHaveTextContent('жирный');
|
|
expect(strong[1]).toHaveTextContent('тоже');
|
|
expect(container.textContent).toBe('жирный и тоже');
|
|
});
|
|
|
|
it('***оба*** — жирный курсив без лишних звёздочек', () => {
|
|
const { container } = markup('***оба***');
|
|
|
|
const strong = container.querySelector('strong');
|
|
expect(strong?.querySelector('em')).toHaveTextContent('оба');
|
|
expect(container.textContent).toBe('оба');
|
|
});
|
|
|
|
it('_курсив_ работает, но подчёркивания внутри слова — нет', () => {
|
|
const { container } = markup('_курсив_ и foo_bar_baz');
|
|
|
|
expect(container.querySelector('em')).toHaveTextContent('курсив');
|
|
expect(container.textContent).toBe('курсив и foo_bar_baz');
|
|
});
|
|
|
|
it('внутри кода маркеры не разбираются', () => {
|
|
const { container } = markup('`==не mark==` и блок:\n```\n**не жирный**\n```');
|
|
|
|
expect(container.querySelector('mark')).toBeNull();
|
|
expect(container.querySelector('strong')).toBeNull();
|
|
expect(container.querySelector('code')).toHaveTextContent('==не mark==');
|
|
expect(container.querySelector('pre')?.textContent).toContain('**не жирный**');
|
|
});
|
|
});
|
|
|
|
describe('разметка: незакрытые маркеры и безопасность', () => {
|
|
it('незакрытые маркеры остаются обычным текстом', () => {
|
|
const content = '==фыв **жирный *курсив ~~зач _подч ||спойлер `код';
|
|
const { container } = markup(content);
|
|
|
|
expect(container.textContent).toBe(content);
|
|
expect(container.querySelector('mark')).toBeNull();
|
|
expect(container.querySelector('strong')).toBeNull();
|
|
expect(container.querySelector('em')).toBeNull();
|
|
expect(container.querySelector('s')).toBeNull();
|
|
expect(container.querySelector('button')).toBeNull();
|
|
});
|
|
|
|
it('ссылка с опасным протоколом остаётся текстом', () => {
|
|
const { container } = markup('javascript:alert(1) и data:text/html,<b>x</b>');
|
|
|
|
expect(container.querySelector('a')).toBeNull();
|
|
expect(container.textContent).toContain('javascript:alert(1)');
|
|
expect(container.textContent).toContain('data:text/html,<b>x</b>');
|
|
});
|
|
|
|
it('HTML из сообщения не выполняется', () => {
|
|
const { container } = markup('<img src=x onerror=alert(1)> <script>alert(2)</script>');
|
|
|
|
expect(container.querySelector('img')).toBeNull();
|
|
expect(container.querySelector('script')).toBeNull();
|
|
expect(container.textContent).toContain('<script>alert(2)</script>');
|
|
});
|
|
|
|
it('автоссылки http(s) открываются в новой вкладке', () => {
|
|
const { container } = markup('смотри https://example.com/a.');
|
|
|
|
const link = container.querySelector('a');
|
|
expect(link).toHaveAttribute('href', 'https://example.com/a');
|
|
expect(link).toHaveAttribute('rel', 'noreferrer noopener');
|
|
expect(link).toHaveAttribute('target', '_blank');
|
|
});
|
|
});
|
|
|
|
describe('разметка: спойлер', () => {
|
|
it('скрывает текст до клика и раскрывается', async () => {
|
|
const visitor = userEvent.setup();
|
|
const { container } = markup('секрет: ||тайна||');
|
|
|
|
const spoiler = container.querySelector('button');
|
|
expect(spoiler).toHaveTextContent('тайна');
|
|
expect(screen.getByRole('button', { name: 'Показать спойлер' })).toHaveAttribute(
|
|
'aria-pressed',
|
|
'false',
|
|
);
|
|
|
|
await visitor.click(screen.getByRole('button', { name: 'Показать спойлер' }));
|
|
|
|
expect(screen.getByRole('button', { name: 'Скрыть спойлер' })).toHaveAttribute(
|
|
'aria-pressed',
|
|
'true',
|
|
);
|
|
expect(container.textContent).toBe('секрет: тайна');
|
|
});
|
|
|
|
it('незакрытый спойлер остаётся текстом', () => {
|
|
const { container } = markup('||тайна');
|
|
|
|
expect(container.querySelector('button')).toBeNull();
|
|
expect(container.textContent).toBe('||тайна');
|
|
});
|
|
});
|
|
|
|
describe('разметка: упоминания каналов', () => {
|
|
const guild: SessionGuild = {
|
|
id: 'g-1',
|
|
name: 'Сервер',
|
|
owner_id: 'user-1',
|
|
is_main: false,
|
|
member_count: 1,
|
|
my_role_ids: [],
|
|
my_permissions: [],
|
|
channels: [{ id: 'c-1', name: 'общий', type: 'text', position: 0 }],
|
|
emojis: [],
|
|
};
|
|
|
|
it('подсвечивает канал из снапшота и не трогает неизвестный', () => {
|
|
useSessionStore.setState({ guilds: [guild] });
|
|
|
|
const { container } = markup('см. #общий и #неттакого');
|
|
|
|
const spans = Array.from(container.querySelectorAll('span'));
|
|
const known = spans.filter((span) => span.textContent === '#общий');
|
|
expect(known).toHaveLength(1);
|
|
expect(known[0]).toHaveClass('bg-accent/15');
|
|
expect(spans.some((span) => span.textContent === '#неттакого')).toBe(false);
|
|
expect(container.textContent).toBe('см. #общий и #неттакого');
|
|
});
|
|
|
|
it('без снапшота #канал остаётся обычным текстом', () => {
|
|
const { container } = markup('см. #общий');
|
|
|
|
expect(container.querySelectorAll('span')).toHaveLength(0);
|
|
expect(container.textContent).toBe('см. #общий');
|
|
});
|
|
|
|
it('рендерит ссылку на комнату <#id> именем из снапшота, иначе #id', () => {
|
|
useSessionStore.setState({ guilds: [guild] });
|
|
|
|
const { container } = markup('см. <#c-1> и <#999>');
|
|
|
|
const spans = Array.from(container.querySelectorAll('span'));
|
|
expect(spans.some((span) => span.textContent === '#общий')).toBe(true);
|
|
expect(spans.some((span) => span.textContent === '#999')).toBe(true);
|
|
// Угловые скобки не остаются текстом.
|
|
expect(container.textContent).toBe('см. #общий и #999');
|
|
});
|
|
|
|
it('не ломает разметку рядом с упоминанием, ссылкой и кодом', () => {
|
|
useSessionStore.setState({ guilds: [guild] });
|
|
|
|
const { container } = render(
|
|
<MessageContent
|
|
content={'<@42> пишет в <#c-1>: https://example.com/#якорь\n`<#c-1>`'}
|
|
resolveMention={() => ({ name: 'Аля', isMe: false })}
|
|
/>,
|
|
);
|
|
|
|
expect(container.textContent).toBe('@Аля пишет в #общий: https://example.com/#якорь\n<#c-1>');
|
|
// Ссылка осталась ссылкой целиком, вместе с якорем.
|
|
expect(container.querySelector('a')).toHaveAttribute('href', 'https://example.com/#якорь');
|
|
// Внутри кода разметка не разбирается.
|
|
expect(container.querySelector('code')).toHaveTextContent('<#c-1>');
|
|
});
|
|
});
|