564 lines
34 KiB
TypeScript
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`);
|
|
}
|
|
});
|