waybetter-for-x/apps/desktop/tests/area-compose.test.ts
2026-10-01 06:01:13 +07:00

564 lines
34 KiB
TypeScript

// Compose+ (src/areas/compose): Alt-text Reminder and Reply Guard as feature modules, compared with
// the plugins they replace; the Drafts button and the Emoji Picker where they stay for now; the new
// "Posting as" chip, length meter and snippets on a small composer DOM. The table-level checks
// (every module starts and stops clean, one observer, the budget) are in areas.test.ts.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { GENERIC_ALT, missingAlt } from '../src/areas/compose/altTextReminder.ts';
import { insertText, targetComposer } from '../src/areas/compose/composer.ts';
import { FEATURES, POWER } from '../src/areas/compose/features.ts';
import { MODULES } from '../src/areas/compose/index.ts';
import { LENGTH_SCHEMA, measure, weightedLength } from '../src/areas/compose/lengthMeter.ts';
import { accountHue } from '../src/areas/compose/postingAs.ts';
import { REPLY_GUARD_SCHEMA, formatAge, replyReason, type ReplyGuardSettings } from '../src/areas/compose/replyGuard.ts';
import { SNIPPETS_SCHEMA, SNIPPET_LIMITS, expansionAt, parseSnippets, snippetLabel } from '../src/areas/compose/snippets.ts';
import { X_TABLE } from '../src/areas/table.ts';
import { normalizeSettings } from '../../../waybetter/packages/runtime/src/settingsForm.ts';
import { areaPage, flagsWith, messages } from './helpers/areaPage.ts';
const BUNDLED = new URL('../src-tauri/bundled-plugins/', import.meta.url);
const OWNER = 'bt-compose-plus.plugin.js';
/** The old plugin, loaded the way native code loads it, to compare the port against. */
function oldPlugin(file: string): Record<string, unknown> {
const module = { exports: {} as unknown };
new Function('module', 'exports', readFileSync(new URL(file, BUNDLED), 'utf8'))(module, module.exports);
return module.exports as Record<string, unknown>;
}
/** A module's word, as `ctx.text` gives it in English (plurals resolved for the counts used here). */
const words = (namespace: string) => (key: string, params: Record<string, string | number> = {}) =>
(messages[`plugin.${namespace}.${key}`] ?? key)
.replace(/\{(\w+), plural, one \{([^{}]*)\} other \{([^{}]*)\}\}/g, (_, name: string, one: string, other: string) => (Number(params[name]) === 1 ? one : other).replace('#', String(params[name])))
.replace(/\{(\w+)\}/g, (match, name: string) => (name in params ? String(params[name]) : match));
/** The flags with every other area switched off: what runs is Compose+'s alone, whatever the other areas turn on by default. */
const only = (patch: Record<string, boolean> = {}) =>
flagsWith({ ...Object.fromEntries(X_TABLE.areas.filter(area => area.id !== 'compose').map(area => [`area.${area.id}`, false])), ...patch });
const silently =<T>(run: () => T): T => {
const original = console.error;
console.error = () => {};
try { return run(); } finally { console.error = original; }
};
test('Compose+: the Drafts button stays in the runtime, the Emoji Picker in its file, the rest are modules', () => {
const byId = Object.fromEntries(FEATURES.map(feature => [feature.id, feature]));
assert.deepEqual([byId.composerDrafts!.backing, byId.composerDrafts!.default], ['app', true], 'the flag and its default are unchanged');
assert.deepEqual([byId['compose.emojiPicker']!.backing, byId['compose.emojiPicker']!.file], ['plugin', 'bt-emoji-picker.plugin.js']);
assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.filter(feature => feature.backing === 'module').map(feature => feature.id).sort());
for (const [flag, file] of [['compose.altTextReminder', 'bt-alt-text-reminder.plugin.js'], ['compose.replyGuard', 'bt-reply-guard.plugin.js']] as const) {
assert.equal(byId[flag]!.supersedes, file);
assert.equal(byId[flag]!.default, false, 'what was an opt-in plugin stays off');
}
// Power User never turns on something that holds a click back.
assert.ok(!POWER.includes('compose.replyGuard'));
// The area's switch takes the runtime-backed Drafts button with it.
assert.equal(flagsWith({})['composerDrafts'], true);
assert.equal(flagsWith({ 'area.compose': false })['composerDrafts'], false);
});
test('Alt-text Reminder: counts the same images as the plugin it replaces', () => {
const Old = oldPlugin('alt-text-reminder.plugin.js') as unknown as { prototype: { missing(root: unknown): number } };
const img = (alt: string | null) => ({ getAttribute: () => alt });
const root = (alts: Array<string | null>) => ({ querySelectorAll: (selector: string) => (selector === '[data-testid="attachments"] img' ? alts.map(img) : []) });
for (const alts of [[], [null], ['Image'], [' image ', 'A cat on a sofa'], ['Görsel', '画像', 'photo', 'Photo of the harbour'], ['', 'media', 'Bild', 'x']]) {
const expected = Old.prototype.missing(root(alts));
assert.equal(missingAlt(root(alts) as unknown as ParentNode), expected, JSON.stringify(alts));
}
assert.equal(missingAlt(root(['Image', 'image', 'A described one']) as unknown as ParentNode), 2);
assert.ok(!GENERIC_ALT.test('Image of a boat'), 'a description that starts with a generic word counts as one');
// Its words are the plugin's own keys.
assert.equal(words('alt-text-reminder')('missing', { count: 1 }), '1 image has no description (alt text)');
assert.equal(words('alt-text-reminder')('headsUp', { count: 2 }), 'Heads up: 2 images without alt text');
});
test('Alt-text Reminder: only where a composer can be; reads a click on Post and never stops it', () => {
const env = areaPage('/home');
const idle = env.listeners();
env.registry.setFlags(only({ 'compose.altTextReminder': true }));
assert.deepEqual(env.registry.active(), ['compose.altTextReminder']);
assert.deepEqual(env.live(), ['wbf-compose.altTextReminder']);
assert.deepEqual(env.api.commands.list(), [], 'no settings, no command');
// Post with two undescribed images in the open dialog: one toast, the click is left alone.
const images = [{ getAttribute: () => 'Image' }, { getAttribute: () => 'A described one' }, { getAttribute: () => '' }];
const dialog = { querySelectorAll: () => images };
const button = { closest: (selector: string) => (selector === '[role="dialog"]' ? dialog : null) };
let touched = 0;
const click = (target: unknown) => env.doc.emit('click', { target, preventDefault() { touched += 1; }, stopPropagation() { touched += 1; }, stopImmediatePropagation() { touched += 1; } });
click({ closest: (selector: string) => (selector.includes('tweetButton') ? button : null) });
assert.deepEqual(env.toasts, ['Heads up: 2 images without alt text']);
click({ closest: () => null });
assert.equal(env.toasts.length, 1, 'a click elsewhere says nothing');
assert.equal(touched, 0, 'the click on Post goes on to X untouched');
// X's composer page keeps it running; notifications and settings have no composer.
env.go('/compose/post');
assert.deepEqual(env.registry.active(), ['compose.altTextReminder']);
env.go('/notifications');
assert.deepEqual(env.registry.active(), []);
assert.deepEqual(env.live(), []);
assert.equal(env.listeners(), idle);
env.registry.dispose();
});
test('Reply Guard: the same settings and the same question as the plugin it replaces', () => {
const Old = oldPlugin('reply-guard.plugin.js') as unknown as {
DEFAULTS: ReplyGuardSettings; normalize(saved: unknown): ReplyGuardSettings; formatAge(days: number): string;
reason(settings: ReplyGuardSettings, context: unknown): string | null;
};
const text = words('reply-guard');
assert.deepEqual(normalizeSettings(REPLY_GUARD_SCHEMA, null), Old.DEFAULTS, 'someone who enabled the plugin and never opened its settings sees no change');
// Everything the plugin could have stored reads back the same; a damaged store takes the defaults.
for (const saved of [{ oldDays: 0, strangers: true }, { oldDays: 3650, strangers: false }, { oldDays: 7 }, { strangers: true }, { oldDays: 99999, strangers: 'yes' }, { oldDays: -4 }, 'broken', null]) {
assert.deepEqual(normalizeSettings(REPLY_GUARD_SCHEMA, saved), Old.normalize(saved), JSON.stringify(saved));
}
for (const days of [0.2, 1, 45, 59.9, 60, 364, 365, 800, 4000]) assert.equal(formatAge(days, text), Old.formatAge(days), `${days} days`);
const now = Date.parse('2026-09-30T12:00:00Z');
const ago = (days: number) => new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
let asked = 0;
for (const settings of [{ oldDays: 30, strangers: false }, { oldDays: 30, strangers: true }, { oldDays: 0, strangers: true }, { oldDays: 0, strangers: false }]) {
for (const time of [ago(1), ago(29.9), ago(30), ago(400), null, 'not a date']) {
for (const following of [true, false, null]) {
for (const own of [false, true]) {
const context = { time, now, handle: 'someone', following, own };
const why = replyReason(settings, context, text);
assert.equal(why, Old.reason(settings, context), JSON.stringify({ settings, context }));
if (why) asked += 1;
if (own || (following !== false && (!time || time === 'not a date'))) assert.equal(why, null, 'your own post, or nothing known: never asks');
}
}
}
}
assert.ok(asked > 0);
assert.equal(replyReason({ oldDays: 30, strangers: true }, { time: ago(400), now, handle: 'someone', following: false, own: false }, text), 'This post is 1 year old. You don\'t follow @someone.');
// Its words are the plugin's own keys (`words: 'reply-guard'`): every label and hint of the form exists.
for (const field of REPLY_GUARD_SCHEMA) if (field.type !== 'divider') {
assert.ok(messages[`plugin.reply-guard.${field.label}`], field.label);
if (field.hint) assert.ok(messages[`plugin.reply-guard.${field.hint}`], field.hint);
}
});
/** A rendered post as `api.tweet` reads it, with its reply button. */
function post(handle: string, time: string) {
const link = { getAttribute: () => `/${handle}/status/1` };
const stamp = { closest: (selector: string) => (selector.includes('/status/') ? link : null), getAttribute: () => time };
let clicks = 0;
const article = {
closest: () => article,
contains: () => false,
querySelector: (selector: string) => (selector === '[data-testid="reply"]' ? button : null),
querySelectorAll: (selector: string) => (selector === 'time' ? [stamp] : []),
};
const button = { isConnected: true, click() { clicks += 1; }, closest: (selector: string) => (selector.includes('article') ? (selector.includes('reply') ? button : article) : null) };
return { article, button, clicks: () => clicks };
}
test('Reply Guard: leaves a fresh post alone, never blocks a reply it cannot ask about, never clicks by itself', () => {
const env = areaPage('/home');
const idle = env.listeners();
env.store.set('bt-plugin:reply-guard:settings', JSON.stringify({ oldDays: 10, strangers: false }));
env.registry.setFlags(only({ 'compose.replyGuard': true }));
assert.deepEqual(env.registry.active(), ['compose.replyGuard']);
assert.deepEqual(env.api.commands.list().map(command => [command.id, command.title]).sort(), [[`${OWNER}:replyGuard.settings`, 'Reply Guard'], [`${OWNER}:settings`, 'Compose+ settings']]);
const day = 24 * 60 * 60 * 1000;
const click = (target: unknown) => {
let held = 0;
silently(() => env.win.emit('click', { target, preventDefault() { held += 1; }, stopImmediatePropagation() { held += 1; } }));
return held;
};
const fresh = post('someone', new Date(Date.now() - 2 * day).toISOString());
assert.equal(click(fresh.button), 0, 'a post from two days ago: X opens its reply box as usual');
// An old post asks (the decision is compared with the plugin's above). This page has no real DOM,
// so the question cannot be drawn: then the click goes on to X, never a reply blocked without a question.
const old = post('someone', new Date(Date.now() - 45 * day).toISOString());
assert.equal(click(old.button), 0);
// X's "r" on the focused post takes the same path; typing never does.
Object.assign(env.doc, { activeElement: fresh.article });
let prevented = 0;
silently(() => env.win.emit('keydown', { key: 'r', target: fresh.article, preventDefault() { prevented += 1; }, stopImmediatePropagation() { prevented += 1; } }));
assert.equal(prevented, 0);
// Nothing is ever clicked for you unless you answered "Reply anyway".
assert.equal(fresh.clicks() + old.clicks(), 0);
// Anything that is not a reply button is not looked at.
assert.equal(click({ closest: () => null }), 0);
// The settings stay where the plugin kept them.
assert.deepEqual(JSON.parse(env.store.get('bt-plugin:reply-guard:settings')!), { oldDays: 10, strangers: false });
env.registry.setFlags(only({}));
assert.deepEqual(env.api.commands.list(), []);
assert.equal(env.listeners(), idle);
env.registry.dispose();
});
// ---- The new features: a small DOM for X's composer --------------------------------------------
type Compound = { tag: string; attrs: Array<[string, string, string | null]> };
const compound = (source: string): Compound => ({
tag: /^[a-z]+/i.exec(source)?.[0].toLowerCase() ?? '',
attrs: [...source.matchAll(/\[([\w-]+)(?:(\^?=)"([^"]*)")?\]/g)].map(match => [match[1]!, match[2] ?? '', match[3] ?? null]),
});
class Txt {
nodeType = 3;
parentElement: El | null = null;
nodeValue: string;
constructor(text: string) { this.nodeValue = text; }
get textContent() { return this.nodeValue; }
get isConnected(): boolean { return !!this.parentElement?.isConnected; }
}
/** An element with what the Compose+ modules use of one: attributes, children, simple selectors. */
class El {
nodeType = 1;
parentElement: El | null = null;
children: Array<El | Txt> = [];
attrs = new Map<string, string>();
title = '';
root = false;
clicks = 0;
style = { cssText: '', props: {} as Record<string, string>, setProperty(name: string, value: string) { this.props[name] = value; } };
tag: string;
constructor(tag: string, attrs: Record<string, string> = {}, ...children: Array<El | string>) {
this.tag = tag;
for (const [name, value] of Object.entries(attrs)) this.attrs.set(name, value);
this.append(...children);
}
get isConnected(): boolean { return this.root || !!this.parentElement?.isConnected; }
get elements() { return this.children.filter((child): child is El => child instanceof El); }
get firstElementChild() { return this.elements[0] ?? null; }
get previousElementSibling() { const all = this.parentElement?.elements ?? []; return all[all.indexOf(this) - 1] ?? null; }
get nextElementSibling() { const all = this.parentElement?.elements ?? []; return all[all.indexOf(this) + 1] ?? null; }
get textContent(): string { return this.children.map(child => child.textContent).join(''); }
set textContent(text: string) { this.replaceChildren(...(text ? [text] : [])); }
getAttribute(name: string) { return this.attrs.get(name) ?? null; }
hasAttribute(name: string) { return this.attrs.has(name); }
setAttribute(name: string, value: string) { this.attrs.set(name, String(value)); }
removeAttribute(name: string) { this.attrs.delete(name); }
private adopt(nodes: Array<El | Txt | string>) {
return nodes.map(node => {
const child = typeof node === 'string' ? new Txt(node) : node;
if (child instanceof El) child.remove();
child.parentElement = this;
return child;
});
}
append(...nodes: Array<El | Txt | string>) { this.children.push(...this.adopt(nodes)); }
prepend(...nodes: Array<El | Txt | string>) { this.children.unshift(...this.adopt(nodes)); }
replaceChildren(...nodes: Array<El | Txt | string>) { for (const child of this.children) child.parentElement = null; this.children = []; this.append(...nodes); }
before(node: El) { const parent = this.parentElement!; node.remove(); node.parentElement = parent; parent.children.splice(parent.children.indexOf(this), 0, node); }
remove() { const parent = this.parentElement; if (!parent) return; parent.children.splice(parent.children.indexOf(this), 1); this.parentElement = null; }
contains(node: El | Txt | null): boolean { for (let at: El | Txt | null = node; at; at = at.parentElement) if (at === this) return true; return false; }
focus() {}
click() { this.clicks += 1; }
private is(part: Compound) {
return (!part.tag || part.tag === this.tag) && part.attrs.every(([name, op, value]) => {
const mine = this.attrs.get(name);
return mine !== undefined && (op === '' || (op === '=' ? mine === value : mine.startsWith(value!)));
});
}
matches(selector: string): boolean {
return selector.split(',').some(alternative => {
const parts = alternative.trim().split(/\s+/).map(compound);
if (!this.is(parts.pop()!)) return false;
let at = this.parentElement;
for (const part of parts.reverse()) {
while (at && !at.is(part)) at = at.parentElement;
if (!at) return false;
at = at.parentElement;
}
return true;
});
}
closest(selector: string): El | null { for (let at: El | null = this; at; at = at.parentElement) if (at.matches(selector)) return at; return null; }
querySelectorAll(selector: string): El[] {
const out: El[] = [];
const walk = (node: El) => { for (const child of node.elements) { if (child.matches(selector)) out.push(child); walk(child); } };
walk(this);
return out;
}
querySelector(selector: string) { return this.querySelectorAll(selector)[0] ?? null; }
/** The subtree as markup, to compare before and after. */
html(): string {
const attrs = [...this.attrs].map(([name, value]) => ` ${name}="${value}"`).join('');
return `<${this.tag}${attrs}>${this.children.map(child => (child instanceof El ? child.html() : child.nodeValue)).join('')}</${this.tag}>`;
}
}
const EDITOR = { 'data-testid': 'tweetTextarea_0', role: 'textbox', contenteditable: 'true' };
/** X's page with the account button and one composer (editor, toolbar with Post). */
function composerPage(handle = 'fishies') {
const block = new El('div', { 'data-block': 'true' });
const editor = new El('div', EDITOR, new El('div', { 'data-contents': 'true' }, block));
const postButton = new El('button', { 'data-testid': 'tweetButtonInline' }, 'Post');
const toolbar = new El('div', { 'data-testid': 'toolBar' }, new El('div', { role: 'tablist' }), postButton);
const area = new El('div', {}, new El('div', {}, editor), toolbar);
const avatar = new El('div', { 'data-testid': `UserAvatar-Container-${handle}` });
const account = new El('button', { 'data-testid': 'SideNav_AccountSwitcher_Button' }, avatar);
const body = new El('body', {}, new El('header', {}, account), new El('main', {}, new El('div', { 'data-testid': 'primaryColumn' }, area)));
body.root = true;
return { body, editor, block, toolbar, postButton, area, avatar, account };
}
type FakeRange = { start?: [unknown, number]; end?: [unknown, number]; contents?: unknown; collapsed?: boolean; commonAncestorContainer?: unknown };
type FakeSelection = {
rangeCount: number; isCollapsed: boolean; anchorNode: unknown; anchorOffset: number; ranges: FakeRange[];
getRangeAt(index: number): unknown; removeAllRanges(): void; addRange(range: FakeRange): void;
};
/** `areaPage` whose document is `page` (styles still go to the recorded list). */
function composeEnv(page: ReturnType<typeof composerPage>, path = '/home') {
const env = areaPage(path);
const style = env.doc.createElement as (tag: string) => unknown;
const executed: Array<{ text: string; range: FakeRange }> = [];
let selection: FakeSelection | null = null;
Object.assign(env.doc, {
body: page.body,
createElement: (tag: string) => (tag === 'style' ? style(tag) : new El(tag)),
querySelectorAll: (selector: string) => page.body.querySelectorAll(selector),
querySelector: (selector: string) => page.body.querySelector(selector),
createRange: () => {
const range: FakeRange & Record<string, unknown> = {
setStart(node: unknown, offset: number) { range.start = [node, offset]; range.commonAncestorContainer = node; },
setEnd(node: unknown, offset: number) { range.end = [node, offset]; },
selectNodeContents(node: unknown) { range.contents = node; range.commonAncestorContainer = node; },
collapse() { range.collapsed = true; },
};
return range;
},
execCommand: (command: string, _ui: boolean, text: string) => { assert.equal(command, 'insertText'); executed.push({ text, range: selection!.ranges[0]! }); return true; },
});
Object.assign(env.win, { getSelection: () => selection });
return {
...env, executed,
/** A plain caret after `offset` characters of `node`. */
caret(node: Txt, offset: number) {
const ranges: FakeRange[] = [];
selection = {
rangeCount: 1, isCollapsed: true, anchorNode: node, anchorOffset: offset, ranges,
getRangeAt: () => ({ cloneRange: () => ({ commonAncestorContainer: node, start: [node, offset] }) }),
removeAllRanges() { ranges.length = 0; }, addRange(range) { ranges.push(range); },
};
},
};
}
test('length meter: counts the way X counts', () => {
const cases: Array<[string, number]> = [
['', 0], ['hello', 5], ['héllo wörld', 11], ['line one\nline two', 17],
['日本語', 6], ['한국어', 6], ['مرحبا', 5], ['привет', 6],
['👍', 2], ['👍🏽', 2], ['👨‍👩‍👧‍👦', 2], ['🇹🇷', 2], ['#️⃣', 2], ['ok 👍 ok', 8],
['é', 1],
['https://example.com', 23], ['http://a.co', 23], ['see https://example.com/a/very/long/path?with=a&query=string#and-a-fragment now', 4 + 23 + 4],
['example.com', 23], ['read example.org/page.', 5 + 23 + 1], ['(https://example.com)', 1 + 23 + 1],
['two https://a.com https://b.com', 4 + 23 + 1 + 23],
['mail me@example.com', 19], ['v1.2 and a.b are not links', 26], ['end of sentence.Next one', 24],
];
for (const [text, expected] of cases) assert.equal(weightedLength(text), expected, JSON.stringify(text));
assert.equal(weightedLength('a'.repeat(280)), 280);
assert.deepEqual(measure('a'.repeat(200), 280), { count: 200, limit: 280, state: 'ok', posts: 1 });
assert.equal(measure('a'.repeat(260), 280).state, 'near');
assert.equal(measure('a'.repeat(280), 280).state, 'near', 'exactly at the limit still fits');
assert.deepEqual(measure('a'.repeat(281), 280), { count: 281, limit: 280, state: 'over', posts: 2 });
assert.equal(measure('日'.repeat(300), 280).posts, 3);
assert.equal(measure('a'.repeat(281), 25000).state, 'ok', 'a longer limit is the owner\'s setting');
assert.deepEqual(normalizeSettings(LENGTH_SCHEMA, null), { limit: 280 });
});
test('"Posting as": one colour per account, whatever the case of the handle', () => {
assert.equal(accountHue('Fishies'), accountHue('fishies'));
assert.notEqual(accountHue('fishies'), accountHue('someone'));
for (const handle of ['a', 'fishies', 'someone_else', 'A_15_char_handl']) {
const hue = accountHue(handle);
assert.ok(Number.isInteger(hue) && hue >= 0 && hue < 360, handle);
}
});
test('"Posting as" and the length meter: one line above the toolbar, kept current, gone without a trace', () => {
const page = composerPage('fishies');
const before = page.body.html();
const env = composeEnv(page);
const idle = env.listeners();
const both = { 'compose.postingAs': true, 'compose.lengthMeter': true };
env.registry.setFlags(only(both));
assert.deepEqual(env.registry.active().sort(), ['compose.lengthMeter', 'compose.postingAs']);
const bar = page.toolbar.previousElementSibling!;
assert.ok(bar.hasAttribute('data-bt-compose-bar'), 'the line is right above X\'s toolbar');
const chip = bar.querySelector('[data-bt-compose-chip]')!;
assert.equal(chip.textContent, 'Posting as @fishies');
assert.equal(chip.getAttribute('data-bt-compose-chip'), 'fishies');
assert.equal(chip.style.props['--bt-compose-hue'], String(accountHue('fishies')));
assert.match(chip.title, /@fishies/);
assert.equal(bar.querySelector('[data-bt-compose-meter]'), null, 'an empty composer has nothing to measure');
// Typing: the meter follows, in the same line.
page.block.textContent = 'hello https://example.com/a/long/link';
env.doc.emit('input', {});
assert.equal(page.body.querySelectorAll('[data-bt-compose-bar]').length, 1, 'both features share the line');
const meter = bar.querySelector('[data-bt-compose-meter]')!;
assert.deepEqual([meter.textContent, meter.getAttribute('data-bt-compose-meter')], ['29 / 280', 'ok']);
const settled = page.body.html();
env.mutate();
env.mutate();
assert.equal(page.body.html(), settled, 'a re-render of X changes nothing that is already right');
page.block.textContent = 'a'.repeat(300);
env.mutate();
assert.deepEqual([meter.textContent, meter.getAttribute('data-bt-compose-meter')], ['300 / 280 · at least 2 posts as a thread', 'over']);
// A second line of X's editor counts its line break.
page.block.textContent = 'one';
const second = new El('div', { 'data-block': 'true' }, 'two');
page.block.parentElement!.append(second);
env.mutate();
assert.equal(meter.textContent, '7 / 280');
// The limit is the owner's setting, stored under the area's namespace.
env.store.set('bt-plugin:compose-plus.lengthMeter:settings', JSON.stringify({ limit: 5 }));
env.registry.setFlags(only({ 'compose.postingAs': true }));
assert.equal(bar.querySelector('[data-bt-compose-meter]'), null, 'the meter off: the chip keeps the line');
env.registry.setFlags(only(both));
assert.equal(bar.querySelector('[data-bt-compose-meter]')!.textContent, '7 / 5 · at least 2 posts as a thread');
// X switched the account in this tab: the chip follows. No account button: no chip, no guess.
page.avatar.setAttribute('data-testid', 'UserAvatar-Container-someone');
env.mutate();
assert.equal(bar.querySelector('[data-bt-compose-chip]')!.textContent, 'Posting as @someone');
page.account.remove();
env.mutate();
assert.equal(bar.querySelector('[data-bt-compose-chip]'), null);
assert.ok(bar.isConnected, 'the meter still has its line');
// X took the toolbar away: the line goes with it; back again, so is the line.
const parent = page.toolbar.parentElement!;
page.toolbar.remove();
env.mutate();
assert.equal(page.body.querySelectorAll('[data-bt-compose-bar]').length, 0);
parent.append(page.toolbar);
page.body.firstElementChild!.append(page.account);
env.mutate();
assert.equal(page.body.querySelectorAll('[data-bt-compose-chip], [data-bt-compose-meter]').length, 2);
// Only where a composer can be.
env.go('/notifications');
assert.deepEqual(env.registry.active(), []);
assert.equal(page.body.querySelectorAll('[data-bt-compose-bar]').length, 0);
env.go('/compose/post');
assert.equal(page.body.querySelectorAll('[data-bt-compose-chip], [data-bt-compose-meter]').length, 2);
// Off: X's page is what it was, and Post was never touched.
env.registry.setFlags(only({}));
second.remove();
page.block.textContent = '';
page.avatar.setAttribute('data-testid', 'UserAvatar-Container-fishies');
assert.equal(page.body.html(), before);
assert.deepEqual(env.live(), []);
assert.equal(env.listeners(), idle);
assert.equal(page.postButton.clicks, 0);
env.registry.dispose();
});
test('snippets: lines to snippets, and the shortcut that was just typed', () => {
assert.deepEqual(parseSnippets(['sig = Best regards,\\nRobin', 'site=https://waybetter.fyi', 'A line without a name', ' ', 'empty =', 'a name = with spaces is text', `${'x'.repeat(30)} = too long a name`]), [
{ name: 'sig', text: 'Best regards,\nRobin' },
{ name: 'site', text: 'https://waybetter.fyi' },
{ name: '', text: 'A line without a name' },
{ name: '', text: 'a name = with spaces is text' },
{ name: '', text: `${'x'.repeat(30)} = too long a name` },
]);
const list = parseSnippets(['sig = Best regards', 'Hi-2 = Hello there']);
assert.deepEqual(expansionAt(';sig ', list), { back: 5, text: 'Best regards ' });
assert.deepEqual(expansionAt('Thanks! ;SIG ', list), { back: 5, text: 'Best regards ' });
assert.deepEqual(expansionAt('ok ;hi-2 ', list), { back: 6, text: 'Hello there ' });
for (const typed of [';sig', 'sig ', ';nope ', 'a;sig ', ';sig ', ';sig x', '; sig ', '']) assert.equal(expansionAt(typed, list), null, JSON.stringify(typed));
assert.equal(expansionAt(';sig ', []), null);
assert.equal(snippetLabel({ name: 'sig', text: 'Best regards,\nRobin' }), ';sig Best regards, Robin');
assert.equal(snippetLabel({ name: '', text: 'x'.repeat(100) }).length, 72);
assert.deepEqual(normalizeSettings(SNIPPETS_SCHEMA, null), { items: [], expand: true });
assert.equal((normalizeSettings(SNIPPETS_SCHEMA, { items: Array.from({ length: 300 }, (_, i) => `n${i} = t`) }).items as string[]).length, SNIPPET_LIMITS.items);
});
test('snippets: inserted as typed text at the caret, only when asked, never posted', () => {
const page = composerPage();
const env = composeEnv(page);
const idle = env.listeners();
const win = env.win as unknown as Window & typeof globalThis;
env.store.set('bt-plugin:compose-plus.snippets:settings', JSON.stringify({ items: ['sig = Best regards'], expand: true }));
env.registry.setFlags(only({ 'compose.snippets': true }));
assert.deepEqual(env.api.commands.list().map(command => [command.id, command.title]).sort(), [[`${OWNER}:settings`, 'Compose+ settings'], [`${OWNER}:snippets.insert`, 'Insert a snippet']]);
// Typing the shortcut and a space replaces exactly those characters, in the text node the caret is in.
const typed = new Txt('Thanks ;sig ');
page.block.append(typed);
env.caret(typed, 12);
env.doc.emit('input', {});
assert.equal(env.executed.length, 1);
assert.equal(env.executed[0]!.text, 'Best regards ');
assert.deepEqual([env.executed[0]!.range.start, env.executed[0]!.range.end], [[typed, 7], [typed, 12]]);
// Anything else typed is left alone; so is a caret outside a composer.
typed.nodeValue = 'Thanks ;sigx ';
env.caret(typed, 13);
env.doc.emit('input', {});
const outside = new Txt(';sig ');
page.account.append(outside);
env.caret(outside, 5);
env.doc.emit('input', {});
assert.equal(env.executed.length, 1);
// Turned off in its settings: the list stays for the palette, typing expands nothing.
env.store.set('bt-plugin:compose-plus.snippets:settings', JSON.stringify({ items: ['sig = Best regards'], expand: false }));
env.registry.setFlags(only({}));
env.registry.setFlags(only({ 'compose.snippets': true }));
typed.nodeValue = ';sig ';
env.caret(typed, 5);
env.doc.emit('input', {});
assert.equal(env.executed.length, 1);
// The palette's insert goes to the composer the selection is in, at that selection.
const target = targetComposer(win)!;
assert.equal(target.editor, page.editor as unknown);
assert.ok(insertText(win, target, 'Hello'));
assert.deepEqual([env.executed[1]!.text, env.executed[1]!.range.start], ['Hello', [typed, 5]]);
// No selection in a composer: the only composer on the page, at the end of its text.
env.caret(outside, 0);
const fallback = targetComposer(win)!;
assert.deepEqual([fallback.editor, fallback.range], [page.editor, null]);
insertText(win, fallback, 'At the end');
assert.deepEqual([env.executed[2]!.text, env.executed[2]!.range.contents, env.executed[2]!.range.collapsed], ['At the end', page.editor, true]);
// Two composers and no caret in either: no guess.
const other = new El('div', {}, new El('div', EDITOR));
page.area.parentElement!.append(other);
assert.equal(targetComposer(win), null);
other.remove();
// A composer that X removed meanwhile takes nothing.
page.editor.remove();
assert.equal(insertText(win, fallback, 'late'), false);
assert.equal(env.executed.length, 3);
assert.equal(page.postButton.clicks, 0, 'Post is never pressed');
env.registry.setFlags(only({}));
assert.deepEqual(env.api.commands.list(), []);
assert.equal(env.listeners(), idle);
env.registry.dispose();
});
test('Compose+: the new features are off until turned on, Power User turns on the calm ones, the palette budget holds', () => {
const byId = Object.fromEntries(FEATURES.map(feature => [feature.id, feature]));
for (const flag of ['compose.postingAs', 'compose.lengthMeter', 'compose.snippets']) {
assert.equal(byId[flag]!.default, false, flag);
assert.deepEqual(byId[flag]!.surfaces, ['timeline', 'composer'], flag);
assert.ok(POWER.includes(flag), flag);
assert.ok(messages[byId[flag]!.label!] && messages[byId[flag]!.description!], flag);
}
// Every label and hint of the generated settings forms has its word.
for (const [name, schema] of [['lengthMeter', LENGTH_SCHEMA], ['snippets', SNIPPETS_SCHEMA]] as const) {
for (const field of schema) if (field.type !== 'divider') {
assert.ok(messages[`plugin.compose-plus.${name}.${field.label}`], `${name}.${field.label}`);
if (field.hint) assert.ok(messages[`plugin.compose-plus.${name}.${field.hint}`], `${name}.${field.hint}`);
}
}
for (const key of ['postingAs.label', 'postingAs.title', 'lengthMeter.count', 'lengthMeter.over', 'snippets.pick', 'snippets.edit', 'snippets.none', 'snippets.noComposer']) assert.ok(messages[`plugin.compose-plus.${key}`], key);
// Everything of the area on at once, on a post page: three of the five palette commands.
const all = Object.fromEntries(MODULES.map(module => [module.id, true]));
const env = areaPage('/someone/status/1');
env.registry.setFlags(only(all));
assert.deepEqual(env.registry.active().sort(), MODULES.map(module => module.id).sort());
assert.deepEqual(env.api.commands.list().map(command => command.id).sort(), [`${OWNER}:replyGuard.settings`, `${OWNER}:settings`, `${OWNER}:snippets.insert`]);
env.registry.setFlags(only({ ...all, 'area.compose': false }));
assert.deepEqual(env.registry.active(), [], 'the area\'s switch stops all of them');
env.registry.dispose();
// No module makes a request, observes on its own or reaches for the globals.
for (const file of ['altTextReminder', 'replyGuard', 'postingAs', 'lengthMeter', 'snippets', 'composer']) {
const source = readFileSync(new URL(`../src/areas/compose/${file}.ts`, import.meta.url), 'utf8');
assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|sendBeacon|WebSocket|EventSource|\.src\s*=/, `${file}: no request`);
assert.doesNotMatch(source, /new MutationObserver|(?<![.\w])(?:window|document)\./, `${file}: everything through ctx`);
}
});