fix(install): LIVEKIT_URL попадает в compose приложения
Шаблон compose подставляет переменные из явного списка: LIVEKIT_PUBLIC_URL не был в нём, поэтому приложение получало пустой адрес LiveKit и голос оставался выключенным. Добавлены LIVEKIT_API_KEY/SECRET/PUBLIC_URL в рендер compose.
This commit is contained in:
@@ -1069,6 +1069,8 @@ render_all_templates() {
|
|||||||
"MAX_UPLOAD_SIZE=$((EFFECTIVE_UPLOAD_MB * 1024 * 1024))" \
|
"MAX_UPLOAD_SIZE=$((EFFECTIVE_UPLOAD_MB * 1024 * 1024))" \
|
||||||
"APP_CPUS=${PROFILE_APP_CPUS}" "APP_MEMORY_LIMIT=${PROFILE_APP_LIMIT}" \
|
"APP_CPUS=${PROFILE_APP_CPUS}" "APP_MEMORY_LIMIT=${PROFILE_APP_LIMIT}" \
|
||||||
"LIVEKIT_CPUS=${PROFILE_LIVEKIT_CPUS}" "LIVEKIT_MEMORY_LIMIT=${PROFILE_LIVEKIT_LIMIT}" \
|
"LIVEKIT_CPUS=${PROFILE_LIVEKIT_CPUS}" "LIVEKIT_MEMORY_LIMIT=${PROFILE_LIVEKIT_LIMIT}" \
|
||||||
|
"LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \
|
||||||
|
"LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}" \
|
||||||
"CADDY_CPUS=1.0" "CADDY_MEMORY_LIMIT=256m" \
|
"CADDY_CPUS=1.0" "CADDY_MEMORY_LIMIT=256m" \
|
||||||
"CADDY_IMAGE_TAG=${CADDY_IMAGE_TAG}" "LIVEKIT_IMAGE_TAG=${LIVEKIT_IMAGE_TAG}" \
|
"CADDY_IMAGE_TAG=${CADDY_IMAGE_TAG}" "LIVEKIT_IMAGE_TAG=${LIVEKIT_IMAGE_TAG}" \
|
||||||
"GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \
|
"GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ export function isValidEmojiName(name: string): boolean {
|
|||||||
return CUSTOM_EMOJI_NAME_PATTERN.test(name);
|
return CUSTOM_EMOJI_NAME_PATTERN.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const guildEmojisQueryKey = (guildId: string) =>
|
export const guildEmojisQueryKey = (guildId: string) => ['guilds', guildId, 'emojis'] as const;
|
||||||
['guilds', guildId, 'emojis'] as const;
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { Fragment, useMemo, type ReactNode, type RefObject } from 'react';
|
|||||||
|
|
||||||
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
|
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
|
||||||
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
||||||
import {
|
import { useGuildEmojiDirectory, type GuildEmojiDirectory } from '@/components/chat/useGuildEmojis';
|
||||||
useGuildEmojiDirectory,
|
|
||||||
type GuildEmojiDirectory,
|
|
||||||
} from '@/components/chat/useGuildEmojis';
|
|
||||||
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
|
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
|
||||||
import {
|
import {
|
||||||
hasMarkers,
|
hasMarkers,
|
||||||
|
|||||||
@@ -42,18 +42,21 @@ export function EmojiPicker({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
const serverEmojis = useGuildEmojis(guildId);
|
const serverEmojis = useGuildEmojis(guildId);
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCloseRef.current = onClose;
|
onCloseRef.current = onClose;
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Поиск сбрасываем при каждом открытии: иначе список «залипал» бы от прошлого раза.
|
// Поиск сбрасываем при открытии: подстройка состояния во время рендера —
|
||||||
useEffect(() => {
|
// документированный приём React, эффект дал бы лишний кадр со старым списком.
|
||||||
|
if (prevOpen !== open) {
|
||||||
|
setPrevOpen(open);
|
||||||
if (open) {
|
if (open) {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
}
|
}
|
||||||
}, [open]);
|
}
|
||||||
|
|
||||||
const found = useMemo(() => {
|
const found = useMemo(() => {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
|
import { CustomEmojiImage } from '@/components/chat/CustomEmojiImage';
|
||||||
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
import { useChannelDirectory, type ChannelDirectory } from '@/components/chat/useChannelDirectory';
|
||||||
import {
|
import { useGuildEmojiDirectory, type GuildEmojiDirectory } from '@/components/chat/useGuildEmojis';
|
||||||
useGuildEmojiDirectory,
|
|
||||||
type GuildEmojiDirectory,
|
|
||||||
} from '@/components/chat/useGuildEmojis';
|
|
||||||
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
|
import { customEmojiShortcode, parseCustomEmoji } from '@/lib/customEmoji';
|
||||||
import {
|
import {
|
||||||
parseBlocks,
|
parseBlocks,
|
||||||
|
|||||||
@@ -153,13 +153,7 @@ function MessageBody({
|
|||||||
* подставляем «участник». Неизвестное событие и мусорный JSON показываем как
|
* подставляем «участник». Неизвестное событие и мусорный JSON показываем как
|
||||||
* есть: ничего не теряем и не падаем.
|
* есть: ничего не теряем и не падаем.
|
||||||
*/
|
*/
|
||||||
function SystemMessageBody({
|
function SystemMessageBody({ message, authors }: { message: Message; authors: AuthorDirectory }) {
|
||||||
message,
|
|
||||||
authors,
|
|
||||||
}: {
|
|
||||||
message: Message;
|
|
||||||
authors: AuthorDirectory;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const event = parseSystemMessage(message.content);
|
const event = parseSystemMessage(message.content);
|
||||||
|
|
||||||
@@ -345,9 +339,7 @@ export function MessageItem({
|
|||||||
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
|
} ${highlight ? 'gl-highlight rounded-[var(--radius-md)]' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="sr-only">{t('chat.types.system')}</span>
|
<span className="sr-only">{t('chat.types.system')}</span>
|
||||||
{message.content === '' ? null : (
|
{message.content === '' ? null : <SystemMessageBody message={message} authors={authors} />}
|
||||||
<SystemMessageBody message={message} authors={authors} />
|
|
||||||
)}
|
|
||||||
<time dateTime={message.created_at} className="text-[10px] text-fg-muted/70">
|
<time dateTime={message.created_at} className="text-[10px] text-fg-muted/70">
|
||||||
{formatMessageTime(message.created_at, i18n.language)}
|
{formatMessageTime(message.created_at, i18n.language)}
|
||||||
</time>
|
</time>
|
||||||
|
|||||||
@@ -53,8 +53,3 @@ export function customEmojiShortcode(name: string): string {
|
|||||||
export function customEmojiToken(name: string, fileId: string, animated = false): string {
|
export function customEmojiToken(name: string, fileId: string, animated = false): string {
|
||||||
return `<${animated ? 'a' : ''}:${name}:${fileId}>`;
|
return `<${animated ? 'a' : ''}:${name}:${fileId}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Является ли строка токеном кастомного эмодзи. */
|
|
||||||
export function isCustomEmojiToken(value: string): boolean {
|
|
||||||
return parseCustomEmoji(value) !== null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function canAdministerGuild(
|
|||||||
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
|
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
|
||||||
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
|
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
|
||||||
* проверит права в конкретной комнате.
|
* проверит права в конкретной комнате.
|
||||||
*/export function canManageMessages(
|
*/ export function canManageMessages(
|
||||||
permissions: readonly string[],
|
permissions: readonly string[],
|
||||||
isInstanceAdmin = false,
|
isInstanceAdmin = false,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
@@ -183,9 +183,7 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
|
|||||||
{formatBytes(emoji.size_bytes, i18n.language)}
|
{formatBytes(emoji.size_bytes, i18n.language)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{emoji.animated ? (
|
{emoji.animated ? <Badge>{t('settings.servers.emojis.animated')}</Badge> : null}
|
||||||
<Badge>{t('settings.servers.emojis.animated')}</Badge>
|
|
||||||
) : null}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label={t('settings.servers.emojis.renameLabel', {
|
aria-label={t('settings.servers.emojis.renameLabel', {
|
||||||
@@ -287,7 +285,11 @@ export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
|
|||||||
disabled={upload.isPending || file === null || name.trim() === '' || full}
|
disabled={upload.isPending || file === null || name.trim() === '' || full}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
{t(upload.isPending ? 'settings.servers.emojis.uploading' : 'settings.servers.emojis.upload')}
|
{t(
|
||||||
|
upload.isPending
|
||||||
|
? 'settings.servers.emojis.uploading'
|
||||||
|
: 'settings.servers.emojis.upload',
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
import { guildEmojisQueryKey } from '@/api/emojis';
|
||||||
import {
|
import {
|
||||||
connectGateway,
|
connectGateway,
|
||||||
disconnectGateway,
|
disconnectGateway,
|
||||||
@@ -366,11 +367,10 @@ async function invalidateGuildQueries(guildId: string): Promise<void> {
|
|||||||
|
|
||||||
/** Инвалидация списка эмодзи сервера после `GUILD_EMOJIS_UPDATE`. */
|
/** Инвалидация списка эмодзи сервера после `GUILD_EMOJIS_UPDATE`. */
|
||||||
async function invalidateEmojiQueries(guildId: string): Promise<void> {
|
async function invalidateEmojiQueries(guildId: string): Promise<void> {
|
||||||
const [{ getQueryClient }, emojis] = await Promise.all([
|
// Ключ запроса берём из статического импорта: `api/emojis` не зависит от
|
||||||
import('@/lib/queryClient'),
|
// сторов, поэтому цикла «стор → query-client → стор» здесь нет.
|
||||||
import('@/api/emojis'),
|
const { getQueryClient } = await import('@/lib/queryClient');
|
||||||
]);
|
await getQueryClient().invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) });
|
||||||
await getQueryClient().invalidateQueries({ queryKey: emojis.guildEmojisQueryKey(guildId) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Держит URL в согласии с выбранным сервером/комнатой после удалений. */
|
/** Держит URL в согласии с выбранным сервером/комнатой после удалений. */
|
||||||
|
|||||||
@@ -198,11 +198,7 @@ describe('кастомные эмодзи: зеркало композера', (
|
|||||||
const mirrorRef = createRef<HTMLDivElement>();
|
const mirrorRef = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ComposerMirror
|
<ComposerMirror value="<:pepe:900>" selection={{ start: 0, end: 0 }} mirrorRef={mirrorRef} />,
|
||||||
value="<:pepe:900>"
|
|
||||||
selection={{ start: 0, end: 0 }}
|
|
||||||
mirrorRef={mirrorRef}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mirrorRef.current?.querySelector('img')).toBeNull();
|
expect(mirrorRef.current?.querySelector('img')).toBeNull();
|
||||||
@@ -217,7 +213,12 @@ describe('кастомные эмодзи: пикер', () => {
|
|||||||
const picked: string[] = [];
|
const picked: string[] = [];
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<EmojiPicker open guildId="g-1" onClose={() => undefined} onPick={(emoji) => picked.push(emoji)} />,
|
<EmojiPicker
|
||||||
|
open
|
||||||
|
guildId="g-1"
|
||||||
|
onClose={() => undefined}
|
||||||
|
onPick={(emoji) => picked.push(emoji)}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const section = screen.getByTestId('emoji-picker-server');
|
const section = screen.getByTestId('emoji-picker-server');
|
||||||
@@ -229,16 +230,16 @@ describe('кастомные эмодзи: пикер', () => {
|
|||||||
await visitor.type(screen.getByLabelText('Поиск эмодзи сервера'), 'party');
|
await visitor.type(screen.getByLabelText('Поиск эмодзи сервера'), 'party');
|
||||||
expect(within(section).queryByText(':pepe:')).toBeNull();
|
expect(within(section).queryByText(':pepe:')).toBeNull();
|
||||||
|
|
||||||
await visitor.click(within(section).getByRole('button', { name: 'Выбрать эмодзи party_parrot' }));
|
await visitor.click(
|
||||||
|
within(section).getByRole('button', { name: 'Выбрать эмодзи party_parrot' }),
|
||||||
|
);
|
||||||
expect(picked).toEqual(['<a:party_parrot:901>']);
|
expect(picked).toEqual(['<a:party_parrot:901>']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('без сервера секция эмодзи сервера не показывается', () => {
|
it('без сервера секция эмодзи сервера не показывается', () => {
|
||||||
useSessionStore.setState({ guilds: [guildWithEmojis()] });
|
useSessionStore.setState({ guilds: [guildWithEmojis()] });
|
||||||
|
|
||||||
render(
|
render(<EmojiPicker open guildId={null} onClose={() => undefined} onPick={() => undefined} />);
|
||||||
<EmojiPicker open guildId={null} onClose={() => undefined} onPick={() => undefined} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
|
expect(screen.queryByTestId('emoji-picker-server')).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -473,9 +474,7 @@ describe('кастомные эмодзи: реакции', () => {
|
|||||||
|
|
||||||
describe('кастомные эмодзи: снапшот и событие шлюза', () => {
|
describe('кастомные эмодзи: снапшот и событие шлюза', () => {
|
||||||
it('читает эмодзи из READY и обновляет их по GUILD_EMOJIS_UPDATE', () => {
|
it('читает эмодзи из READY и обновляет их по GUILD_EMOJIS_UPDATE', () => {
|
||||||
const snapshot = makeReadySnapshot([
|
const snapshot = makeReadySnapshot([{ id: 'g-1', name: 'Сервер', emojis: [pepe] }]);
|
||||||
{ id: 'g-1', name: 'Сервер', emojis: [pepe] },
|
|
||||||
]);
|
|
||||||
dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: snapshot });
|
dispatchGatewayEvent({ op: 0, t: 'READY', s: 1, d: snapshot });
|
||||||
|
|
||||||
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe]);
|
expect(useSessionStore.getState().guilds[0]?.emojis).toEqual([pepe]);
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ function routes(options: RoutesOptions = {}) {
|
|||||||
{
|
{
|
||||||
match: '/api/v1/guilds/g-1/emojis',
|
match: '/api/v1/guilds/g-1/emojis',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
response: options.uploadResponse ?? (() => json({ emoji: { ...pepe, id: 'e-3', name: 'kek' } })),
|
response:
|
||||||
|
options.uploadResponse ?? (() => json({ emoji: { ...pepe, id: 'e-3', name: 'kek' } })),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
match: '/api/v1/users/@me/guilds',
|
match: '/api/v1/users/@me/guilds',
|
||||||
@@ -169,6 +170,25 @@ describe('настройки сервера: секция «Эмодзи»', ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('принимает файл перетаскиванием в область загрузки', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const fetchMock = installFetch(routes());
|
||||||
|
|
||||||
|
const { dialog } = await openEmojisSection();
|
||||||
|
fireEvent.drop(within(dialog).getByTestId('emoji-dropzone'), {
|
||||||
|
dataTransfer: { files: [emojiFile('drop.png', 'image/png')] },
|
||||||
|
});
|
||||||
|
// Имя файла видно в области загрузки — файл принят.
|
||||||
|
expect(await within(dialog).findByText(/drop\.png/)).toBeVisible();
|
||||||
|
|
||||||
|
await visitor.type(within(dialog).getByLabelText('Имя эмодзи'), 'drop');
|
||||||
|
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(uploadForm(fetchMock)?.get('name')).toBe('drop');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('не отправляет файл неподдерживаемого формата', async () => {
|
it('не отправляет файл неподдерживаемого формата', async () => {
|
||||||
const fetchMock = installFetch(routes());
|
const fetchMock = installFetch(routes());
|
||||||
|
|
||||||
@@ -213,9 +233,7 @@ describe('настройки сервера: секция «Эмодзи»', ()
|
|||||||
);
|
);
|
||||||
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
|
await visitor.click(within(dialog).getByRole('button', { name: 'Загрузить' }));
|
||||||
|
|
||||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
|
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Имя эмодзи: 2–32 символа');
|
||||||
'Имя эмодзи: 2–32 символа',
|
|
||||||
);
|
|
||||||
expect(uploadForm(fetchMock)).toBeUndefined();
|
expect(uploadForm(fetchMock)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ import type { Message } from '@/api/messages';
|
|||||||
import { MessageList } from '@/components/chat/MessageList';
|
import { MessageList } from '@/components/chat/MessageList';
|
||||||
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
|
||||||
import { parseSystemMessage, systemMessageUserId } from '@/lib/systemMessage';
|
import { parseSystemMessage, systemMessageUserId } from '@/lib/systemMessage';
|
||||||
import { makeMessage, makeUser, json, installFetch, installGatewaySocket, messagesRoutes, renderApp, resetStores } from './helpers';
|
import {
|
||||||
|
makeMessage,
|
||||||
|
makeUser,
|
||||||
|
json,
|
||||||
|
installFetch,
|
||||||
|
installGatewaySocket,
|
||||||
|
messagesRoutes,
|
||||||
|
renderApp,
|
||||||
|
resetStores,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Системные сообщения (AGENT.md §7.6): сервер пишет их с `type: "system"`,
|
* Системные сообщения (AGENT.md §7.6): сервер пишет их с `type: "system"`,
|
||||||
@@ -104,9 +113,7 @@ describe('системные сообщения: лента', () => {
|
|||||||
it('member_kick — «{имя} покинул сервер (исключён)»', () => {
|
it('member_kick — «{имя} покинул сервер (исключён)»', () => {
|
||||||
renderList([systemMessage('{"event":"member_kick","user_id":"user-2"}')]);
|
renderList([systemMessage('{"event":"member_kick","user_id":"user-2"}')]);
|
||||||
|
|
||||||
expect(screen.getByTestId('message-m-1')).toHaveTextContent(
|
expect(screen.getByTestId('message-m-1')).toHaveTextContent('Боб покинул сервер (исключён)');
|
||||||
'Боб покинул сервер (исключён)',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('channel_created — комната и голосовая комната', () => {
|
it('channel_created — комната и голосовая комната', () => {
|
||||||
@@ -127,7 +134,9 @@ describe('системные сообщения: лента', () => {
|
|||||||
systemMessage('Аля вошла', 'm-2'),
|
systemMessage('Аля вошла', 'm-2'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(screen.getByTestId('message-m-1')).toHaveTextContent('{"event":"guild_boost","level":2}');
|
expect(screen.getByTestId('message-m-1')).toHaveTextContent(
|
||||||
|
'{"event":"guild_boost","level":2}',
|
||||||
|
);
|
||||||
expect(screen.getByTestId('message-m-2')).toHaveTextContent('Аля вошла');
|
expect(screen.getByTestId('message-m-2')).toHaveTextContent('Аля вошла');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user