Merge branch 'master' of github.com:bitsocialnet/5chan

This commit is contained in:
Tommaso Casaburi
2026-06-27 17:47:01 +07:00
11 changed files with 157 additions and 21 deletions
+27 -1
View File
@@ -278,6 +278,7 @@ vi.mock('../components/reply-modal', () => ({
default: ({ parentCid, postCid }: { parentCid: string; postCid: string }) => createElement('div', { 'data-testid': 'reply-modal' }, `${parentCid}:${postCid}`), default: ({ parentCid, postCid }: { parentCid: string; postCid: string }) => createElement('div', { 'data-testid': 'reply-modal' }, `${parentCid}:${postCid}`),
})); }));
let latestHash = '';
let latestLocation = ''; let latestLocation = '';
let container: HTMLDivElement; let container: HTMLDivElement;
let root: Root; let root: Root;
@@ -287,7 +288,8 @@ const LocationProbe = () => {
const location = useLocation(); const location = useLocation();
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
latestLocation = `${location.pathname}${location.search}`; latestLocation = `${location.pathname}${location.search}`;
}, [location.pathname, location.search]); latestHash = location.hash;
}, [location.hash, location.pathname, location.search]);
return null; return null;
}; };
@@ -304,6 +306,7 @@ const renderApp = async (initialEntry: string) => {
App = (await import('../app')).default; App = (await import('../app')).default;
} }
latestHash = '';
latestLocation = initialEntry; latestLocation = initialEntry;
act(() => { act(() => {
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(App!), createElement(LocationProbe))); root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(App!), createElement(LocationProbe)));
@@ -350,6 +353,7 @@ describe('App', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
latestHash = '';
latestLocation = ''; latestLocation = '';
testState.account = { author: { address: '0x123' } }; testState.account = { author: { address: '0x123' } };
testState.accountComments = {}; testState.accountComments = {};
@@ -443,6 +447,21 @@ describe('App', () => {
expect(container.querySelector('[data-testid="not-found-view"]')).toBeNull(); expect(container.querySelector('[data-testid="not-found-view"]')).toBeNull();
}); });
it('redirects unknown board subpaths to catalog search hashes', async () => {
await renderApp('/mu/test');
expect(latestLocation).toBe('/mu/catalog');
expect(latestHash).toBe('#s=test');
});
it('redirects unknown board settings subpaths to catalog search settings hashes', async () => {
await renderApp('/mu/test/settings');
expect(latestLocation).toBe('/mu/catalog/settings');
expect(latestHash).toBe('#s=test');
expect(container.querySelector('[data-testid="settings-modal"]')).toBeTruthy();
});
it('redirects flash board catalog routes to not-found', async () => { it('redirects flash board catalog routes to not-found', async () => {
testState.directories = [ testState.directories = [
{ address: 'music-posting.eth', title: '/mu/ - Music', nsfw: false }, { address: 'music-posting.eth', title: '/mu/ - Music', nsfw: false },
@@ -486,6 +505,13 @@ describe('App', () => {
expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy(); expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy();
}); });
it('canonicalizes board address catalog routes while preserving search hashes', async () => {
await renderApp('/music-posting.eth/catalog#s=test');
expect(latestLocation).toBe('/mu/catalog');
expect(latestHash).toBe('#s=test');
});
it('canonicalizes a direct route for the current resolved directory board', async () => { it('canonicalizes a direct route for the current resolved directory board', async () => {
testState.resolvedDirectoryBoardPath = 'biz'; testState.resolvedDirectoryBoardPath = 'biz';
testState.isDirectoryCandidate = true; testState.isDirectoryCandidate = true;
+7 -1
View File
@@ -32,6 +32,8 @@ import {
isValidBoardModRoute, isValidBoardModRoute,
isValidModRoute, isValidModRoute,
isFlashBoardRoute, isFlashBoardRoute,
isBoardFeedPageNumber,
getCatalogSearchRoute,
} from './lib/utils/route-utils'; } from './lib/utils/route-utils';
import styles from './app.module.css'; import styles from './app.module.css';
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons/board-buttons'; import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons/board-buttons';
@@ -119,6 +121,10 @@ const BoardLayout = () => {
return <Navigate to={{ pathname: getPageOneCanonicalPath(boardIdentifier, pathname), search, hash }} replace />; return <Navigate to={{ pathname: getPageOneCanonicalPath(boardIdentifier, pathname), search, hash }} replace />;
} }
if (boardIdentifier && pageNumber && !isBoardFeedPageNumber(pageNumber)) {
return <Navigate to={getCatalogSearchRoute(boardIdentifier, pageNumber, search, { settings: pathname.endsWith('/settings') })} replace />;
}
if (isCatalogView(pathname, params) && isFlashBoardRoute(boardIdentifier, directories)) { if (isCatalogView(pathname, params) && isFlashBoardRoute(boardIdentifier, directories)) {
return <Navigate to='/not-found' replace />; return <Navigate to='/not-found' replace />;
} }
@@ -142,7 +148,7 @@ const BoardLayout = () => {
const canonicalBoardIdentifier = resolvedDirectoryBoardPath ?? (isDirectoryCandidate ? boardIdentifier : getBoardPath(boardIdentifier, directories)); const canonicalBoardIdentifier = resolvedDirectoryBoardPath ?? (isDirectoryCandidate ? boardIdentifier : getBoardPath(boardIdentifier, directories));
if (canonicalBoardIdentifier !== boardIdentifier) { if (canonicalBoardIdentifier !== boardIdentifier) {
const canonicalPath = pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`); const canonicalPath = pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
return <Navigate to={canonicalPath + (search || '')} replace />; return <Navigate to={canonicalPath + (search || '') + (hash || '')} replace />;
} }
} }
@@ -12,6 +12,7 @@ const testState = vi.hoisted(() => ({
debounceCancelMock: vi.fn(), debounceCancelMock: vi.fn(),
isMobile: false, isMobile: false,
location: { location: {
hash: '',
pathname: '/mu/catalog', pathname: '/mu/catalog',
search: '', search: '',
}, },
@@ -92,6 +93,7 @@ describe('CatalogSearch', () => {
testState.debounceCancelMock.mockReset(); testState.debounceCancelMock.mockReset();
testState.isMobile = false; testState.isMobile = false;
testState.location = { testState.location = {
hash: '',
pathname: '/mu/catalog', pathname: '/mu/catalog',
search: '', search: '',
}; };
@@ -108,10 +110,11 @@ describe('CatalogSearch', () => {
container.remove(); container.remove();
}); });
it('opens from the query param and seeds the catalog search filter', async () => { it('opens from the search hash and seeds the catalog search filter', async () => {
testState.location = { testState.location = {
hash: '#s=linux',
pathname: '/mu/catalog', pathname: '/mu/catalog',
search: '?q=linux', search: '',
}; };
await renderSearch(); await renderSearch();
@@ -121,15 +124,43 @@ describe('CatalogSearch', () => {
expect(queryInput()?.getAttribute('value')).toBe('linux'); expect(queryInput()?.getAttribute('value')).toBe('linux');
}); });
it('clears the catalog search filter when navigation removes the query param', async () => { it('migrates the legacy query param to the search hash', async () => {
testState.location = { testState.location = {
hash: '',
pathname: '/mu/catalog', pathname: '/mu/catalog',
search: '?q=linux', search: '?q=linux',
}; };
await renderSearch(); await renderSearch();
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('linux');
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog#s=linux', { replace: true });
});
it('prefers the search hash when stripping a legacy query param', async () => {
testState.location = { testState.location = {
hash: '#s=hash-value',
pathname: '/mu/catalog',
search: '?t=1w&q=query-value',
};
await renderSearch();
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('hash-value');
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?t=1w#s=hash-value', { replace: true });
});
it('clears the catalog search filter when navigation removes the search hash', async () => {
testState.location = {
hash: '#s=linux',
pathname: '/mu/catalog',
search: '',
};
await renderSearch();
testState.location = {
hash: '',
pathname: '/mu/catalog', pathname: '/mu/catalog',
search: '', search: '',
}; };
@@ -154,7 +185,7 @@ describe('CatalogSearch', () => {
await dispatchInput(input, 'web3'); await dispatchInput(input, 'web3');
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('web3'); expect(testState.setSearchFilterMock).toHaveBeenCalledWith('web3');
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?q=web3', { replace: true }); expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog#s=web3', { replace: true });
await act(async () => { await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })); input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
@@ -5,33 +5,44 @@ import styles from './catalog-search.module.css';
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { getCatalogSearchHash } from '../../lib/utils/route-utils';
const CatalogSearch = () => { const CatalogSearch = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { pathname, search } = useLocation(); const { pathname, search, hash } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchState, setSearchState] = useState({ open: false, value: '' }); const [searchState, setSearchState] = useState({ open: false, value: '' });
const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore(); const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore();
const queryParam = new URLSearchParams(search).get('q') ?? ''; const legacyQueryParam = new URLSearchParams(search).get('q') ?? '';
const openSearch = !!queryParam || searchState.open; const hashSearchParam = new URLSearchParams(hash.replace(/^#/, '')).get('s') ?? '';
const inputValue = searchState.open || searchState.value ? searchState.value : queryParam; const catalogSearchParam = hashSearchParam || legacyQueryParam;
const openSearch = !!catalogSearchParam || searchState.open;
const inputValue = searchState.open || searchState.value ? searchState.value : catalogSearchParam;
useEffect(() => { useEffect(() => {
if (queryParam) { if (legacyQueryParam) {
setSearchFilter(queryParam); const urlParams = new URLSearchParams(search);
urlParams.delete('q');
const newSearch = urlParams.toString();
navigate(`${pathname}${newSearch ? `?${newSearch}` : ''}${getCatalogSearchHash(catalogSearchParam)}`, { replace: true });
}
if (catalogSearchParam) {
setSearchFilter(catalogSearchParam);
return; return;
} }
clearSearchFilter(); clearSearchFilter();
}, [queryParam, setSearchFilter, clearSearchFilter]); }, [catalogSearchParam, clearSearchFilter, legacyQueryParam, navigate, pathname, search, setSearchFilter]);
const updateURL = useCallback( const updateURL = useCallback(
(searchText: string) => { (searchText: string) => {
const urlParams = new URLSearchParams(search); const urlParams = new URLSearchParams(search);
urlParams.delete('q');
if (searchText.trim()) { if (searchText.trim()) {
urlParams.set('q', searchText); const newSearch = urlParams.toString();
} else { navigate(`${pathname}${newSearch ? `?${newSearch}` : ''}${getCatalogSearchHash(searchText)}`, { replace: true });
urlParams.delete('q'); return;
} }
const newSearch = urlParams.toString(); const newSearch = urlParams.toString();
const newPath = pathname + (newSearch ? `?${newSearch}` : ''); const newPath = pathname + (newSearch ? `?${newSearch}` : '');
@@ -349,6 +349,22 @@ describe('Markdown', () => {
expect(container.textContent).toBe('see >>>/fit/, next'); expect(container.textContent).toBe('see >>>/fit/, next');
}); });
it.each([
['>>>/biz/test', '/biz/catalog#s=test'],
['>>>/biz/test-term', '/biz/catalog#s=test-term'],
['>>>/biz/test_term', '/biz/catalog#s=test_term'],
['>>>/board.eth/test', '/board.eth/catalog#s=test'],
])('renders board search path %s as a catalog search hash link', async (quoteLink, href) => {
await renderMarkdown({
content: `see ${quoteLink}.`,
});
const link = container.querySelector('a');
expect(link?.getAttribute('href')).toBe(href);
expect(link?.textContent).toBe(quoteLink);
expect(container.textContent).toBe(`see ${quoteLink}.`);
});
it('normalizes hash-routed 5chan links before passing them to React Router', async () => { it('normalizes hash-routed 5chan links before passing them to React Router', async () => {
testState.internalPathByHref = { testState.internalPathByHref = {
'https://5chan.local/#/mu': '#/mu', 'https://5chan.local/#/mu': '#/mu',
+10 -1
View File
@@ -21,6 +21,7 @@ import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import ExternalNumberQuoteLink from './external-number-quote-link'; import ExternalNumberQuoteLink from './external-number-quote-link';
import { findDirectoryByAddress, useDirectories, type DirectoryCommunity } from '../../hooks/use-directories'; import { findDirectoryByAddress, useDirectories, type DirectoryCommunity } from '../../hooks/use-directories';
import { getDirectoryCodeForBoardAddress } from '../../lib/utils/directory-list-lookup-utils'; import { getDirectoryCodeForBoardAddress } from '../../lib/utils/directory-list-lookup-utils';
import { getCatalogSearchRoute } from '../../lib/utils/route-utils';
import { import {
createDiceRollMarkupRegex, createDiceRollMarkupRegex,
createFortuneBbcodeRegex, createFortuneBbcodeRegex,
@@ -163,7 +164,7 @@ type Token =
const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO][iI][lL][eE][rR]\]/; const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO][iI][lL][eE][rR]\]/;
const MARKDOWN_LINK_REGEX = /(?<!!)\[([^\]\n]+)\]\(\s*([^\n)]*?)\s*\)/; const MARKDOWN_LINK_REGEX = /(?<!!)\[([^\]\n]+)\]\(\s*([^\n)]*?)\s*\)/;
const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/; const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46}|[a-zA-Z0-9_-]+)?|[a-zA-Z0-9\-.]+(?:\/(?:[a-zA-Z0-9]{46}|[a-zA-Z0-9_-]+))?))[.,:;!?]*/;
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/; const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
const URL_REGEX = /https?:\/\/[^\s<>[\]]+/; const URL_REGEX = /https?:\/\/[^\s<>[\]]+/;
type QstBbcodeTag = 'b' | 'i' | 'red' | 'green' | 'blue'; type QstBbcodeTag = 'b' | 'i' | 'red' | 'green' | 'blue';
@@ -283,10 +284,18 @@ function getCrossboardRoute(fullPattern: string): string | null {
const [code, cid] = pathPart.split('/'); const [code, cid] = pathPart.split('/');
return `/${code}/thread/${cid}`; return `/${code}/thread/${cid}`;
} }
if (/^[a-zA-Z0-9]{1,10}\/[a-zA-Z0-9_-]+$/.test(pathPart)) {
const [code, searchText] = pathPart.split('/');
return getCatalogSearchRoute(code, searchText);
}
if (/^[^/]+\/[a-zA-Z0-9]{46}$/.test(pathPart)) { if (/^[^/]+\/[a-zA-Z0-9]{46}$/.test(pathPart)) {
const [address, cid] = pathPart.split('/'); const [address, cid] = pathPart.split('/');
return `/${address}/thread/${cid}`; return `/${address}/thread/${cid}`;
} }
if (/^[^/]+\/[a-zA-Z0-9_-]+$/.test(pathPart)) {
const [address, searchText] = pathPart.split('/');
return getCatalogSearchRoute(address, searchText);
}
return `/${pathPart}`; return `/${pathPart}`;
} }
@@ -3,6 +3,8 @@ import {
areSameBoardAddress, areSameBoardAddress,
extractDirectoryFromTitle, extractDirectoryFromTitle,
getBoardPath, getBoardPath,
getCatalogSearchHash,
getCatalogSearchRoute,
getFeedCacheKey, getFeedCacheKey,
getFeedType, getFeedType,
getPageFromFeedPath, getPageFromFeedPath,
@@ -209,6 +211,17 @@ describe('feed pagination helpers', () => {
}); });
}); });
describe('catalog search route helpers', () => {
it('formats catalog search routes with 4chan-style hash params', () => {
expect(getCatalogSearchHash('test')).toBe('#s=test');
expect(getCatalogSearchRoute('biz', 'test')).toBe('/biz/catalog#s=test');
expect(getCatalogSearchRoute('biz', 'test', '', { settings: true })).toBe('/biz/catalog/settings#s=test');
expect(getCatalogSearchRoute('biz', 'cats and dogs', '?t=1w')).toBe('/biz/catalog?t=1w#s=cats%20and%20dogs');
expect(getCatalogSearchHash(' ')).toBe('');
expect(getCatalogSearchRoute('biz', '')).toBe('/biz/catalog');
});
});
describe('feed cache helpers', () => { describe('feed cache helpers', () => {
it('derives cache keys for feeds and threads', () => { it('derives cache keys for feeds and threads', () => {
expect(getFeedCacheKey('/biz')).toBe('/biz'); expect(getFeedCacheKey('/biz')).toBe('/biz');
+4 -1
View File
@@ -107,10 +107,13 @@ describe('url-utils', () => {
expect(isValidCrossboardPattern('>>>/biz/')).toBe(true); expect(isValidCrossboardPattern('>>>/biz/')).toBe(true);
expect(isValidCrossboardPattern(`>>>/biz/${'a'.repeat(46)}`)).toBe(true); expect(isValidCrossboardPattern(`>>>/biz/${'a'.repeat(46)}`)).toBe(true);
expect(isValidCrossboardPattern('>>>/biz/123')).toBe(true); expect(isValidCrossboardPattern('>>>/biz/123')).toBe(true);
expect(isValidCrossboardPattern('>>>/biz/test')).toBe(true);
expect(isValidCrossboardPattern(`>>>/board.eth/${'b'.repeat(46)}`)).toBe(true); expect(isValidCrossboardPattern(`>>>/board.eth/${'b'.repeat(46)}`)).toBe(true);
expect(isValidCrossboardPattern('>>>/board.eth/123')).toBe(true); expect(isValidCrossboardPattern('>>>/board.eth/123')).toBe(true);
expect(isValidCrossboardPattern('>>>/board.eth/test')).toBe(true);
expect(isValidCrossboardPattern(`>>>/${ipnsKey}`)).toBe(true); expect(isValidCrossboardPattern(`>>>/${ipnsKey}`)).toBe(true);
expect(isValidCrossboardPattern('>>>/invalid/thread-with-short-cid')).toBe(false); expect(isValidCrossboardPattern(`>>>/${ipnsKey}/test`)).toBe(true);
expect(isValidCrossboardPattern('>>>/invalid/thread/extra')).toBe(false);
expect(isValidCrossboardPattern('>>/biz/')).toBe(false); expect(isValidCrossboardPattern('>>/biz/')).toBe(false);
}); });
}); });
+10 -2
View File
@@ -215,10 +215,18 @@ export const isValidBoardModRoute = (pathname: string): boolean => {
return VALID_BOARD_MOD_SUBPATHS.includes(subpath); return VALID_BOARD_MOD_SUBPATHS.includes(subpath);
}; };
/** Page numbers 110 for board feed pagination */ /** Page numbers 1-10 for board feed pagination */
const BOARD_PAGE_REGEX = /^([1-9]|10)$/; const BOARD_PAGE_REGEX = /^([1-9]|10)$/;
const isBoardFeedPageNumber = (segment: string): boolean => BOARD_PAGE_REGEX.test(segment); export const isBoardFeedPageNumber = (segment: string): boolean => BOARD_PAGE_REGEX.test(segment);
export const getCatalogSearchHash = (searchText: string): string => {
const trimmedSearchText = searchText.trim();
return trimmedSearchText ? `#s=${encodeURIComponent(trimmedSearchText)}` : '';
};
export const getCatalogSearchRoute = (boardIdentifier: string, searchText: string, search = '', options?: { settings?: boolean }): string =>
`/${boardIdentifier}/catalog${options?.settings ? '/settings' : ''}${search}${getCatalogSearchHash(searchText)}`;
/** Internal: check if segment is a multiboard root (all, subs, mod) */ /** Internal: check if segment is a multiboard root (all, subs, mod) */
function isMultiboardRoot(segment: string): boolean { function isMultiboardRoot(segment: string): boolean {
+13
View File
@@ -296,6 +296,12 @@ export const isValidCrossboardPattern = (pattern: string): boolean => {
return true; return true;
} }
// Check if it's a directory + catalog search pattern: >>>/biz/test
const directoryCatalogSearchMatch = pathPart.match(/^([a-zA-Z0-9]{1,10})\/([a-zA-Z0-9_-]+)$/);
if (directoryCatalogSearchMatch) {
return true;
}
// Check if it's a full address + thread pattern: >>>/board.eth/fullCid // Check if it's a full address + thread pattern: >>>/board.eth/fullCid
const addressThreadMatch = pathPart.match(/^([^/]+)\/([a-zA-Z0-9]{46})$/); const addressThreadMatch = pathPart.match(/^([^/]+)\/([a-zA-Z0-9]{46})$/);
if (addressThreadMatch) { if (addressThreadMatch) {
@@ -311,6 +317,13 @@ export const isValidCrossboardPattern = (pattern: string): boolean => {
return isValidDomain(address) || isValidIPNSKey(address); return isValidDomain(address) || isValidIPNSKey(address);
} }
// Check if it's a full address + catalog search pattern: >>>/board.eth/test
const addressCatalogSearchMatch = pathPart.match(/^([^/]+)\/([a-zA-Z0-9_-]+)$/);
if (addressCatalogSearchMatch) {
const [, address] = addressCatalogSearchMatch;
return isValidDomain(address) || isValidIPNSKey(address);
}
// Check if it's just a full address pattern: >>>/board.eth // Check if it's just a full address pattern: >>>/board.eth
return isValidDomain(pathPart) || isValidIPNSKey(pathPart); return isValidDomain(pathPart) || isValidIPNSKey(pathPart);
}; };
+1 -1
View File
@@ -931,7 +931,7 @@ describe('Catalog', () => {
testState.searchText = 'asddasd'; testState.searchText = 'asddasd';
testState.hasMore = false; testState.hasMore = false;
await renderCatalog({ initialEntry: '/mu/catalog?q=asddasd', routePath: '/:boardIdentifier/catalog' }); await renderCatalog({ initialEntry: '/mu/catalog#s=asddasd', routePath: '/:boardIdentifier/catalog' });
expect(container.textContent).toContain('nothing_found'); expect(container.textContent).toContain('nothing_found');
expect(container.textContent).not.toContain('no_threads'); expect(container.textContent).not.toContain('no_threads');