// Search+ (src/areas/search): the operator chips and the Explore rule it owns, saved searches, the // advanced search and "Latest by default". The table-level checks (every module starts and stops // clean, one observer, the command budget) are in areas.test.ts. import { readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; import test from 'node:test'; import { BUILDER_FIELDS, composeQuery, readQuery, type BuilderValues } from '../src/areas/search/advancedSearch.ts'; import { FEATURES, POWER } from '../src/areas/search/features.ts'; import { MODULES } from '../src/areas/search/index.ts'; import { LATEST_TAB, searchPage, tabIsFor, wantsLatest } from '../src/areas/search/latestDefault.ts'; import { ROUTER_WAIT_MS, openSearch } from '../src/areas/search/openSearch.ts'; import { MAX_CHIPS, MAX_SAVED, SAVED_SCHEMA, currentQuery, isPinned, parseSaved, savedChips, savedList, togglePinned } from '../src/areas/search/savedSearches.ts'; import { buildSearchUrl, hasOperators } from '../src/core/searchOperators.ts'; import { FEATURE_DEFAULTS } from '../src/inject/features.ts'; import { SEARCH_INPUT, isSearchInput, stripColors } from '../src/inject/searchChips.ts'; import { normalizeSettings } from '../../../waybetter/packages/runtime/src/settingsForm.ts'; import { X_TABLE } from '../src/areas/table.ts'; import { areaPage, flagsWith as tableFlags, messages } from './helpers/areaPage.ts'; const OWNER = 'bt-search-plus.plugin.js'; /** The flags with only Search+ on, so what runs here is Search+'s alone (other areas have features that are on by default). */ const OTHER_AREAS_OFF = Object.fromEntries(X_TABLE.areas.filter(area => area.id !== 'search').map(area => [`area.${area.id}`, false])); /** Its own module that is on by default (the operator chips) is off unless named. */ const flagsWith = (patch: Record = {}) => tableFlags({ ...OTHER_AREAS_OFF, searchOperators: false, ...patch }); const STORE = 'bt-plugin:search-plus.savedSearches:settings'; const word = (feature: string, key: string) => messages[`plugin.search-plus.${feature}.${key}`]; const blank = () => normalizeSettings(BUILDER_FIELDS, null) as BuilderValues; /** The page's address as X's router leaves it (the area page only knows a pathname). */ function at(env: ReturnType, path: string, search = '') { const location = env.win.location as { search?: string; assign?: (url: string) => void }; location.search = search; env.go(path); } test('Search+: the two flags it took over keep their names and defaults; all five features are modules', () => { const byId = Object.fromEntries(FEATURES.map(feature => [feature.id, feature])); assert.deepEqual(Object.keys(byId), ['searchOperators', 'hideExploreContent', 'search.savedSearches', 'search.advancedSearch', 'search.latestDefault']); // The chips and the Explore rule are modules with the flags they always had. for (const flag of ['searchOperators', 'hideExploreContent'] as const) { assert.equal(byId[flag]!.backing, 'module', flag); assert.equal(byId[flag]!.default, FEATURE_DEFAULTS[flag], flag); } assert.deepEqual(MODULES.map(module => module.id), FEATURES.filter(feature => feature.backing === 'module').map(feature => feature.id)); // New features are opt-in; Power User adds the two that only offer something, never the one that clicks a tab. for (const feature of FEATURES.filter(item => item.id.startsWith('search.'))) assert.equal(feature.default, false, feature.id); assert.deepEqual(POWER, ['search.savedSearches', 'search.advancedSearch']); assert.deepEqual(byId['search.latestDefault']!.surfaces, ['search']); // The search box is recognised by its testid (no class of the page is needed, so a test page works too). assert.equal(isSearchInput({ matches: (selector: string) => selector === SEARCH_INPUT }), true); assert.equal(isSearchInput({ matches: () => false }), false); assert.equal(isSearchInput(null), false); }); test('Saved searches: a line is a query or "Name => query"; each query once; pin and unpin', () => { assert.deepEqual(parseSaved('from:@lena filter:links'), { name: 'from:lena filter:links', query: 'from:lena filter:links' }); assert.deepEqual(parseSaved('Design news => from:lena filter:links'), { name: 'Design news', query: 'from:lena filter:links' }); assert.deepEqual(parseSaved(' => cats'), { name: 'cats', query: 'cats' }); assert.equal(parseSaved('Name => '), null); assert.equal(parseSaved(' '), null); const lines = ['cats', 'Pets => CATS', '', 'dogs lang:en']; assert.deepEqual(savedList(lines).map(item => item.query), ['cats', 'dogs lang:en'], 'the same search twice is one'); assert.equal(savedList(Array.from({ length: 80 }, (_, i) => `q${i}`)).length, MAX_SAVED); assert.equal(isPinned(lines, 'Cats'), true); assert.equal(isPinned(lines, 'birds'), false); assert.deepEqual(togglePinned(lines, 'birds'), [...lines, 'birds']); assert.deepEqual(togglePinned(lines, 'cats'), ['', 'dogs lang:en'], 'unpinning removes every line of that search and keeps the rest as typed'); assert.deepEqual(togglePinned(lines, ' '), lines); // The settings form stores exactly these lines. assert.deepEqual(normalizeSettings(SAVED_SCHEMA, null), { searches: [] }); assert.deepEqual(normalizeSettings(SAVED_SCHEMA, { searches: ['a', 3, ' '] }), { searches: ['a'] }); }); test('Saved searches: the chips under the box open X\'s search, offer pin or unpin for what is typed, and fold a long list', () => { const opened: string[] = []; const toggled: string[] = []; let listed = 0; const act = { open: (item: { query: string }) => { opened.push(buildSearchUrl(item.query)); }, toggle: (query: string) => { toggled.push(query); }, list: () => { listed += 1; } }; const words = (key: string, params?: Record) => word('savedSearches', key)!.replace('{count}', String(params?.count ?? '')); const saved = savedList(['Design news => from:lena filter:links', 'cats']); const input = {} as HTMLInputElement; let chips = savedChips(saved, '', words, act); assert.deepEqual(chips.map(chip => [chip.label, chip.title, !!chip.action]), [['Design news', 'from:lena filter:links', false], ['cats', 'cats', false]]); chips[0]!.run(input); chips[1]!.run(input); // Navigations to X's own search page, nothing else: operators open Latest, plain words Top. assert.deepEqual(opened, ['https://x.com/search?q=from%3Alena%20filter%3Alinks&src=typed_query&f=live', 'https://x.com/search?q=cats&src=typed_query']); chips = savedChips(saved, 'dogs', words, act); assert.deepEqual(chips.at(-1)!.label, 'Save this search'); chips.at(-1)!.run(input); chips = savedChips(saved, 'CATS', words, act); assert.deepEqual(chips.at(-1)!.label, 'Remove from saved'); chips.at(-1)!.run(input); assert.deepEqual(toggled, ['dogs', 'CATS']); const many = savedList(Array.from({ length: MAX_CHIPS + 3 }, (_, i) => `q${i}`)); chips = savedChips(many, '', words, act); assert.equal(chips.length, MAX_CHIPS + 1); assert.equal(chips.at(-1)!.label, '3 more'); chips.at(-1)!.run(input); assert.equal(listed, 1); }); test('opening a search goes through X\'s router; the address is loaded the plain way only when X did not take it', () => { const calls: string[] = []; let check: (() => void) | null = null; const location = { origin: 'https://x.com', href: 'https://x.com/home', assign(url: string) { calls.push(`assign ${url}`); } }; const win = { location, document: { title: 'Home / X' }, history: { pushState(state: { key: string }, _title: string, path: string) { calls.push(`push ${path} ${typeof state.key}`); location.href = `https://x.com${path}`; } }, PopStateEvent: class { type: string; constructor(type: string) { this.type = type; } }, dispatchEvent(event: { type: string }) { calls.push(event.type); return true; }, setTimeout(fn: () => void, ms: number) { assert.equal(ms, ROUTER_WAIT_MS); check = fn; return 7; }, }; const page = win as unknown as Window & typeof globalThis; assert.equal(openSearch(page, 'from:ada cats'), 7); assert.deepEqual(calls, ['push /search?q=from%3Aada%20cats&src=typed_query&f=live string', 'popstate'], 'the state X\'s links push, then the event its router hears'); // X drew the results (its title changed): nothing more happens. win.document.title = 'from:ada cats - Search / X'; check!(); assert.equal(calls.length, 2); // X did not take it: the same address, loaded the plain way. openSearch(page, 'dogs'); check!(); assert.equal(calls.at(-1), 'assign https://x.com/search?q=dogs&src=typed_query'); // The owner went elsewhere meanwhile: left alone. openSearch(page, 'birds'); location.href = 'https://x.com/notifications'; const before = calls.length; check!(); assert.equal(calls.length, before); // No history API (or not X's page): the plain way at once. const plain: string[] = []; assert.equal(openSearch({ location: { origin: 'https://x.com', href: 'https://x.com/home', assign(url: string) { plain.push(url); } } } as unknown as Window & typeof globalThis, 'cats'), 0); assert.deepEqual(plain, ['https://x.com/search?q=cats&src=typed_query']); }); test('Saved searches: what is being searched is the box\'s text, else the query of a results page', () => { const page = (pathname: string, search: string) => ({ location: { pathname, search } }) as unknown as Window; assert.equal(currentQuery(page('/home', ''), { value: ' from:@lena cats ' }), 'from:lena cats'); assert.equal(currentQuery(page('/search', '?q=min_faves%3A100%20cats&src=typed_query'), { value: '' }), 'min_faves:100 cats'); assert.equal(currentQuery(page('/search', '?q=cats'), null), 'cats'); assert.equal(currentQuery(page('/home', '?q=cats'), null), '', 'only a results page has a query'); assert.equal(currentQuery(page('/explore', ''), null), ''); }); test('Saved searches in the page: two palette commands and the settings entry; the pin command saves where the settings are', () => { const env = areaPage('/home'); const idle = env.listeners(); env.registry.setFlags(flagsWith({ 'search.savedSearches': true })); assert.ok(env.listeners() > idle, 'the strip under the box listens for the box getting focus'); assert.deepEqual(env.registry.active(), ['search.savedSearches']); const commands = env.api.commands.list(); assert.deepEqual(commands.map(command => command.id).sort(), [`${OWNER}:savedSearches.open`, `${OWNER}:savedSearches.pin`, `${OWNER}:settings`]); assert.equal(commands.find(command => command.id.endsWith('savedSearches.open'))!.title, 'Saved searches'); assert.equal(commands.find(command => command.id.endsWith('savedSearches.pin'))!.title, 'Save or remove this search'); assert.equal(commands.find(command => command.id.endsWith(':settings'))!.title, 'Search+ settings'); // Nothing typed and not on a results page: nothing to save. env.api.commands.run(`${OWNER}:savedSearches.pin`); assert.deepEqual(env.toasts, ['There is no search to save. Type one in the search box first.']); assert.equal(env.store.has(STORE), false); // The text in X's search box is pinned, then unpinned. env.matches.set(SEARCH_INPUT, [{ value: 'from:@lena filter:links' }]); env.api.commands.run(`${OWNER}:savedSearches.pin`); assert.deepEqual(JSON.parse(env.store.get(STORE)!), { searches: ['from:lena filter:links'] }); env.api.commands.run(`${OWNER}:savedSearches.pin`); assert.deepEqual(JSON.parse(env.store.get(STORE)!), { searches: [] }); // On a results page with an empty box, the page's query is the one. env.matches.set(SEARCH_INPUT, [{ value: '' }]); at(env, '/search', '?q=cats&src=typed_query&f=live'); env.api.commands.run(`${OWNER}:savedSearches.pin`); assert.deepEqual(JSON.parse(env.store.get(STORE)!), { searches: ['cats'] }); assert.deepEqual(env.toasts.slice(1), ['Search saved', 'Search removed from saved', 'Search saved']); // A list the owner edited by hand is read as it is (a name, a duplicate, an empty line). env.store.set(STORE, JSON.stringify({ searches: ['News => from:lena', 'from:lena', 'cats'] })); env.api.commands.run(`${OWNER}:savedSearches.pin`); assert.deepEqual(JSON.parse(env.store.get(STORE)!), { searches: ['News => from:lena', 'from:lena'] }); // Full: no more is added, and the owner is told. env.store.set(STORE, JSON.stringify({ searches: Array.from({ length: MAX_SAVED }, (_, i) => `q${i}`) })); env.api.commands.run(`${OWNER}:savedSearches.pin`); assert.equal(JSON.parse(env.store.get(STORE)!).searches.length, MAX_SAVED); assert.equal(env.toasts.at(-1), `You can save up to ${MAX_SAVED} searches. Remove one first.`); env.registry.setFlags(flagsWith({})); assert.deepEqual(env.api.commands.list(), []); assert.equal(env.listeners(), idle, 'the strip under the box is gone with its listeners'); env.registry.dispose(); }); test('Advanced search: every field becomes X\'s own operator; what is not valid is left out', () => { assert.equal(composeQuery(blank()), ''); assert.equal(composeQuery({ ...blank(), words: ' split panes ' }), 'split panes'); assert.equal(composeQuery({ ...blank(), from: '@lena' }), 'from:lena'); assert.equal(composeQuery({ ...blank(), from: '@lena, mira lena' }), '(from:lena OR from:mira)'); assert.equal(composeQuery({ ...blank(), to: 'theo_park' }), 'to:theo_park'); assert.equal(composeQuery({ ...blank(), since: '2026-01-01', until: '2026-02-01' }), 'since:2026-01-01 until:2026-02-01'); for (const filter of ['media', 'links', 'replies']) assert.equal(composeQuery({ ...blank(), filter }), `filter:${filter}`); assert.equal(composeQuery({ ...blank(), minFaves: 100 }), 'min_faves:100'); assert.equal(composeQuery({ ...blank(), lang: 'TR' }), 'lang:tr'); const full: BuilderValues = { words: '"exact phrase" -spam', from: 'lena', to: 'mira', since: '2026-01-01', until: '2026-02-01', filter: 'media', minFaves: 50, lang: 'en' }; assert.equal(composeQuery(full), '"exact phrase" -spam from:lena to:mira since:2026-01-01 until:2026-02-01 filter:media min_faves:50 lang:en'); // Not valid for its operator: left out, never sent as broken syntax. assert.equal(composeQuery({ ...blank(), words: 'cats', from: 'handle! @a_name_longer_than_fifteen', to: '!!!', since: 'yesterday', until: '2026-2-1', filter: 'everything', minFaves: -5, lang: 'english' }), 'cats'); // The result is an ordinary X search: X's own search page, Latest for operators. assert.ok(hasOperators(composeQuery(full))); assert.match(buildSearchUrl(composeQuery(full)), /^https:\/\/x\.com\/search\?q=.+&src=typed_query&f=live$/); }); test('Advanced search: opens with the search already typed, and gives it back unchanged', () => { const query = '"exact phrase" -spam from:lena to:mira since:2026-01-01 until:2026-02-01 filter:media min_faves:50 lang:en'; assert.deepEqual(readQuery(query), { words: '"exact phrase" -spam', from: 'lena', to: 'mira', since: '2026-01-01', until: '2026-02-01', filter: 'media', minFaves: 50, lang: 'en' }); assert.equal(composeQuery(readQuery(query)), query); // Operators the form has no field for (and a second value of a single field) stay as words. assert.equal(readQuery('cats list:123 -filter:replies filter:videos url:example.com min_retweets:5').words, 'cats list:123 -filter:replies filter:videos url:example.com min_retweets:5'); assert.deepEqual(readQuery('since:2026-01-01 since:2026-03-03 lang:en lang:tr'), { ...blank(), words: 'since:2026-03-03 lang:tr', since: '2026-01-01', lang: 'en' }); assert.deepEqual(readQuery(''), blank()); // Every label, hint and option of the form has its English word. for (const field of BUILDER_FIELDS) { if (field.type === 'divider') continue; assert.ok(word('advancedSearch', field.label), field.label); if (field.hint) assert.ok(word('advancedSearch', field.hint), field.hint); if (field.type === 'select') for (const option of field.options) assert.ok(word('advancedSearch', option.label), option.label); } for (const key of ['title', 'chip', 'intro', 'previewEmpty', 'empty', 'cancel', 'fill', 'search']) assert.ok(word('advancedSearch', key), key); for (const field of SAVED_SCHEMA) if (field.type !== 'divider') assert.ok(word('savedSearches', field.label) && word('savedSearches', field.hint!), field.label); for (const key of ['title', 'empty', 'edit', 'close', 'pin', 'unpin', 'more', 'pinCommand', 'pinned', 'unpinned', 'full', 'failed', 'nothing']) assert.ok(word('savedSearches', key), key); }); test('Advanced search in the page: one palette command, no settings of its own', () => { const env = areaPage('/explore'); const idle = env.listeners(); env.registry.setFlags(flagsWith({ 'search.advancedSearch': true })); const one = env.listeners(); assert.deepEqual(env.api.commands.list().map(command => [command.id, command.title]), [[`${OWNER}:advancedSearch.open`, 'Advanced search']]); // With saved searches too: four commands of the five an area may have. env.registry.setFlags(flagsWith({ 'search.advancedSearch': true, 'search.savedSearches': true, 'search.latestDefault': true })); assert.equal(env.api.commands.list().length, 4); assert.equal(env.listeners(), one, 'both features share one strip under the box'); assert.deepEqual(env.registry.active().sort(), ['search.advancedSearch', 'search.savedSearches'], 'Latest by default only runs on a results page'); env.registry.setFlags(flagsWith({ 'area.search': false, 'search.advancedSearch': true, 'search.savedSearches': true })); assert.deepEqual(env.registry.active(), [], 'the plugin off: everything it owns is off'); assert.deepEqual(env.api.commands.list(), []); assert.equal(env.listeners(), idle); env.registry.dispose(); }); test('Latest by default: which page is a search, and when Latest is selected', () => { assert.deepEqual(searchPage('/search', '?q=cats&src=typed_query'), { query: 'cats', tab: null }); assert.deepEqual(searchPage('/search', '?q=cats&f=live'), { query: 'cats', tab: 'live' }); assert.equal(searchPage('/search', ''), null); assert.equal(searchPage('/search', '?q=%20'), null); assert.equal(searchPage('/explore', '?q=cats'), null); assert.equal(wantsLatest({ query: 'cats', tab: null }, null), true); assert.equal(wantsLatest({ query: 'cats', tab: null }, 'cats'), false, 'already decided for this search'); assert.equal(wantsLatest({ query: 'cats', tab: 'user' }, null), false, 'a search that names its tab'); assert.equal(wantsLatest(null, null), false); assert.equal(tabIsFor('/search?q=cats&src=typed_query&f=live', 'cats'), true); assert.equal(tabIsFor('/search?q=dogs&src=typed_query&f=live', 'cats'), false); assert.equal(tabIsFor(null, 'cats'), false); // The tab is found by its address, not by its name: any language. assert.match(LATEST_TAB, /a\[role="tab"\]\[href\*="f=live"\]$/); assert.doesNotMatch(LATEST_TAB, /aria-label|Latest/); }); test('Latest by default in the page: one click per search, never against the user, never off a results page', () => { const env = areaPage('/home'); const location = env.win.location as { search?: string }; let clicks = 0; let href = '/search?q=cats&src=typed_query&f=live'; // X's Latest tab: a click navigates to its address. const tab = { getAttribute: (name: string) => (name === 'href' ? href : null), click() { clicks += 1; location.search = href.slice(href.indexOf('?')); } }; env.matches.set(LATEST_TAB, [tab]); env.registry.setFlags(flagsWith({ 'search.latestDefault': true })); assert.deepEqual(env.registry.active(), [], 'not a results page'); // The tabs are not drawn yet when the page opens: nothing happens until they are. env.matches.set(LATEST_TAB, []); at(env, '/search', '?q=cats&src=typed_query'); assert.deepEqual(env.registry.active(), ['search.latestDefault']); assert.equal(clicks, 0); env.matches.set(LATEST_TAB, [tab]); env.mutate(); assert.equal(clicks, 1); assert.equal(location.search, '?q=cats&src=typed_query&f=live'); env.mutate(); assert.equal(clicks, 1); // Back to Top by hand: it stays on Top. at(env, '/search', '?q=cats&src=typed_query'); env.mutate(); assert.equal(clicks, 1); // A new search while X still shows the old search's tabs: wait for its own tabs. at(env, '/search', '?q=dogs&src=typed_query'); env.mutate(); assert.equal(clicks, 1); href = '/search?q=dogs&src=typed_query&f=live'; env.mutate(); assert.equal(clicks, 2); // A search opened on a tab of its own (People) is left alone, also when the user then picks Top. href = '/search?q=birds&src=typed_query&f=live'; at(env, '/search', '?q=birds&f=user'); env.mutate(); at(env, '/search', '?q=birds'); env.mutate(); assert.equal(clicks, 2); // Off: nothing is left, and nothing is clicked any more. env.registry.setFlags(flagsWith({})); at(env, '/search', '?q=fish'); env.mutate(); assert.equal(clicks, 2); assert.equal(env.observers.live, 0); env.registry.dispose(); }); test('Search+ strips: colors from what the page shows around X\'s search box, readable on light, dim, dark and themed pages', () => { // A chain input -> box -> column -> body, each with its computed colors. const chain = (input: string, ...backgrounds: string[]) => { const nodes = backgrounds.map(backgroundColor => ({ backgroundColor, color: input, parentElement: null as unknown })); for (let i = 0; i < nodes.length - 1; i++) nodes[i]!.parentElement = nodes[i + 1]; const el = { backgroundColor: 'rgba(0, 0, 0, 0)', color: input, parentElement: nodes[0] ?? null }; const win = { getComputedStyle: (node: { backgroundColor: string; color: string }) => node }; return stripColors(win as never, el as never); }; // X light: the box is transparent, the column white, dark text. assert.deepEqual([chain('rgb(15, 20, 25)', 'rgba(0, 0, 0, 0)', 'rgb(255, 255, 255)').bg, chain('rgb(15, 20, 25)', 'rgba(0, 0, 0, 0)', 'rgb(255, 255, 255)').fg], ['rgb(255, 255, 255)', 'rgb(15, 20, 25)']); // X dim / a dark theme: its own background, light text. assert.equal(chain('rgb(247, 249, 249)', 'rgb(21, 32, 43)').bg, 'rgb(21, 32, 43)'); // A theme that paints elsewhere and leaves the body transparent (live: Classic Twitter gave a black strip). assert.equal(chain('rgb(41, 47, 51)', 'rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)', 'rgb(245, 248, 250)', 'rgba(0, 0, 0, 0)').bg, 'rgb(245, 248, 250)'); // Nothing opaque anywhere: the opposite of the text, never black under dark text. assert.equal(chain('rgb(41, 47, 51)', 'rgba(0, 0, 0, 0)').bg, 'rgb(255, 255, 255)'); assert.equal(chain('rgb(231, 233, 234)', 'transparent').bg, 'rgb(0, 0, 0)'); // Translucent layers do not count as the background. assert.equal(chain('rgb(15, 20, 25)', 'rgba(255, 255, 255, 0.5)', 'rgb(250, 250, 250)').bg, 'rgb(250, 250, 250)'); const colors = chain('rgb(15, 20, 25)', 'rgb(255, 255, 255)'); assert.match(colors.muted, /color-mix\(in srgb, rgb\(15, 20, 25\) 65%, rgb\(255, 255, 255\)\)/); // No fixed dark colors are left in either strip's sheet. for (const file of ['../src/inject/searchChips.ts', '../src/areas/search/bar.ts']) { const source = readFileSync(new URL(file, import.meta.url), 'utf8'); assert.doesNotMatch(source, /var\(--bg,#000\)|#8b98a5|#e7e9ea/, file); } });