wayBetter for X
This commit is contained in:
commit
df5dff619e
720 changed files with 147465 additions and 0 deletions
352
apps/desktop/tests/area-search.test.ts
Normal file
352
apps/desktop/tests/area-search.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
// 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<string, boolean> = {}) => 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<typeof areaPage>, 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<string, string | number>) => 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);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue