Files
steam-chat/test/web-message-render.test.ts
2026-08-03 02:22:21 +08:00

403 lines
16 KiB
TypeScript

'use strict';
import type { Context } from 'node:vm';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
type Listener = (event: { currentTarget: FakeElement; target: FakeElement }) => unknown;
class FakeElement {
tagName: string;
className = '';
children: FakeElement[] = [];
dataset: Record<string, string> = {};
href = '';
src = '';
target = '';
rel = '';
srcset = '';
style: Record<string, string> = {};
title = '';
type = '';
alt = '';
hidden = false;
attributes: Record<string, string> = {};
private ownText = '';
private listeners = new Map<string, Listener[]>();
constructor(tagName: string) {
this.tagName = tagName.toUpperCase();
}
append(...nodes: FakeElement[]) {
this.children.push(...nodes);
}
replaceChildren(...nodes: FakeElement[]) {
this.children = [...nodes];
this.ownText = '';
}
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
setAttribute(name: string, value: string) {
this.attributes[name] = value;
}
async dispatch(type: string) {
for (const listener of this.listeners.get(type) || []) {
await listener({ currentTarget: this, target: this });
}
}
get textContent(): string {
return this.ownText + this.children.map((child) => child.textContent).join('');
}
set textContent(value: string) {
this.ownText = String(value);
this.children = [];
}
findByClass(className: string): FakeElement | null {
if (this.className.split(/\s+/).includes(className)) return this;
for (const child of this.children) {
const found = child.findByClass(className);
if (found) return found;
}
return null;
}
findAllByClass(className: string): FakeElement[] {
const matches: FakeElement[] = this.className.split(/\s+/).includes(className) ? [this] : [];
return matches.concat(...this.children.map((child) => child.findAllByClass(className)));
}
findByTag(tagName: string): FakeElement | null {
if (this.tagName === tagName.toUpperCase()) return this;
for (const child of this.children) {
const found = child.findByTag(tagName);
if (found) return found;
}
return null;
}
findAllByTag(tagName: string): FakeElement[] {
const matches: FakeElement[] = this.tagName === tagName.toUpperCase() ? [this] : [];
return matches.concat(...this.children.map((child) => child.findAllByTag(tagName)));
}
}
type WebTestApi = {
renderMessage: (item: Record<string, unknown>) => FakeElement;
};
function loadWebTestApi(): WebTestApi & {
clipboardWrites: string[];
lightbox: FakeElement;
lightboxImage: FakeElement;
} {
const appPath = path.resolve(__dirname, '../web/app.js');
const appSource = fs.readFileSync(appPath, 'utf8');
const bootstrapIndex = appSource.lastIndexOf('bootstrap().catch');
assert.notEqual(bootstrapIndex, -1, 'web app bootstrap marker is required by the test harness');
const appRoot = new FakeElement('div');
const lightbox = new FakeElement('div');
lightbox.hidden = true;
const lightboxImage = new FakeElement('img');
const clipboardWrites: string[] = [];
const sandbox: Context & { __webTest?: WebTestApi } = {
console,
document: {
querySelector(selector: string) {
if (selector === '#app') return appRoot;
if (selector === '#lightbox') return lightbox;
if (selector === '#lightboxImage') return lightboxImage;
return null;
},
createElement(tagName: string) {
return new FakeElement(tagName);
},
createTextNode(text: string) {
const node = new FakeElement('#text');
node.textContent = text;
return node;
},
addEventListener() {}
},
localStorage: { getItem(): null { return null; }, setItem(): void {} },
navigator: { clipboard: { async writeText(value: string) { clipboardWrites.push(value); } } },
location: { protocol: 'http:', host: 'localhost' },
URL,
Headers,
FormData,
Date,
Math,
JSON,
Promise,
setTimeout(callback: () => void) { callback(); return 0; },
clearTimeout,
setInterval,
clearInterval
};
const testSource = `${appSource.slice(0, bootstrapIndex)}\n;globalThis.__webTest = { renderMessage };`;
vm.runInNewContext(testSource, sandbox);
assert.ok(sandbox.__webTest);
return { ...sandbox.__webTest, clipboardWrites, lightbox, lightboxImage };
}
test('web chat renders Steam OpenGraph messages as preview cards', async () => {
const { renderMessage, clipboardWrites } = loadWebTestApi();
const url = 'https://b23.tv/example?share_medium=android&share_source=qq&ts=1234567890';
const imageUrl = 'https://community.steamstatic.com/chat/image/example/share_image.jpg@1200w_630h';
const title = '示例“视频”标题';
const description = '视频播放量 12345、弹幕量 67、点赞数 890';
const message = `[og url="${url}" img="${imageUrl}" title="${title}" desc="${description}"]${url}[/og]`;
const rendered = renderMessage({ id: '1', name: 'tursom', type: 'message', message });
const card = rendered.findByClass('og-card');
assert.doesNotMatch(rendered.textContent, /\[\/?og\b/i);
assert.ok(card);
assert.match(card.textContent, new RegExp(title));
assert.match(card.textContent, new RegExp(description));
assert.equal(card.findByTag('img')?.src, `/proxy/image?url=${encodeURIComponent(imageUrl)}`);
assert.equal(card.findByClass('og-title')?.href, url);
assert.equal(card.findByClass('og-domain')?.textContent, 'B23.TV');
assert.equal(card.findByClass('og-domain')?.href, url);
const copyButton = card.findByClass('og-copy-button');
assert.ok(copyButton);
assert.equal(copyButton.attributes['aria-label'], '复制链接');
await copyButton.dispatch('click');
assert.deepEqual(clipboardWrites, [url]);
const imageLink = card.findByClass('og-image-link');
assert.ok(imageLink);
await card.findByTag('img')?.dispatch('error');
assert.equal(imageLink.hidden, true);
assert.match(card.textContent, new RegExp(title));
});
test('web chat preserves text around multiple OpenGraph cards', () => {
const { renderMessage } = loadWebTestApi();
const message = [
'前文 ',
'[og url="https://first.example/video" title="第一张"]https://first.example/video[/og]',
' 中间 ',
"[og url='https://second.example/post' desc='第二张描述']https://second.example/post[/og]",
' 后文'
].join('');
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
const cards = rendered.findAllByClass('og-card');
assert.equal(cards.length, 2);
assert.match(rendered.textContent, /前文 第一张FIRST\.EXAMPLE 中间 第二张描述SECOND\.EXAMPLE 后文/);
assert.equal(cards[0].findByClass('og-image-link'), null);
assert.equal(cards[1].findByClass('og-title'), null);
});
test('web chat leaves malformed or unsafe OpenGraph markup visible', () => {
const { renderMessage } = loadWebTestApi();
const messages = [
'[og url="https://example.com" title="未闭合"]https://example.com',
'[og title="缺少 URL"]fallback[/og]',
'[og url="javascript:alert(1)" title="危险协议"]fallback[/og]',
'[og url="https://example.com" title=broken]fallback[/og]',
'[og url="https://example.com" extra="unknown"]fallback[/og]'
];
for (const message of messages) {
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
assert.equal(rendered.findByClass('og-card'), null);
assert.equal(rendered.textContent, `Alice${message}`);
}
});
test('web chat decodes escaped quotes in OpenGraph attributes', () => {
const { renderMessage } = loadWebTestApi();
const message = '[og url="https://example.com" title="quoted \\"title\\""]https://example.com[/og]';
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
assert.match(rendered.findByClass('og-card')?.textContent || '', /quoted "title"/);
});
test('web chat preserves non-duplicate OpenGraph body text', () => {
const { renderMessage } = loadWebTestApi();
const message = '[og url="https://example.com" title="示例"]额外说明[/og]';
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
assert.ok(rendered.findByClass('og-card'));
assert.match(rendered.textContent, /示例EXAMPLE\.COM额外说明/);
});
test('web chat handles long unclosed OpenGraph markup without blocking', () => {
const { renderMessage } = loadWebTestApi();
const message = `[og ${'a'.repeat(26)}`;
const startedAt = performance.now();
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
assert.ok(performance.now() - startedAt < 250, 'unclosed markup should be handled in linear time');
assert.equal(rendered.textContent, `Alice${message}`);
});
test('web chat keeps sticker, emoticon, and plain-link rendering around OpenGraph support', () => {
const { renderMessage } = loadWebTestApi();
const message = ':wave: https://example.com [sticker type="happy" limit="0"][/sticker]';
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
assert.ok(rendered.findByClass('emoticon'));
assert.ok(rendered.findByClass('sticker'));
assert.equal(rendered.findByTag('a')?.href, 'https://example.com');
assert.equal(rendered.findByClass('og-card'), null);
});
test('web chat renders both Steam URL BBCode forms without leaking markup', () => {
const { renderMessage } = loadWebTestApi();
const firstUrl = 'https://live.example.com/917818?from=chat&room=main';
const secondUrl = 'https://video.example.com/watch/1906428959';
const message = `前文 [url=${firstUrl}]直播间[/url] 中间 [url]${secondUrl}[/url] 后文`;
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
const links = rendered.findAllByTag('a');
assert.doesNotMatch(rendered.textContent, /\[\/?url\b/i);
assert.match(rendered.textContent, /前文 直播间 中间 https:\/\/video\.example\.com\/watch\/1906428959 后文/);
assert.deepEqual(links.map((link) => link.href), [firstUrl, secondUrl]);
assert.ok(links.every((link) => link.target === '_blank' && link.rel === 'noopener noreferrer'));
});
test('web chat renders Steam emoticon BBCode and complete sticker types', () => {
const { renderMessage } = loadWebTestApi();
const message = [
'[emoticon]steamfacepalm[/emoticon]',
'[sticker type="show love" limit="0"][/sticker]',
'[sticker type="伊埃斯跳舞" limit="0"][/sticker]'
].join(' ');
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
const emoticons = rendered.findAllByClass('emoticon');
const stickers = rendered.findAllByClass('sticker');
assert.doesNotMatch(rendered.textContent, /\[\/?(?:emoticon|sticker)\b/i);
assert.equal(emoticons.length, 1);
assert.equal(emoticons[0].src, 'https://community.cloudflare.steamstatic.com/economy/emoticon/steamfacepalm');
assert.deepEqual(stickers.map((sticker) => sticker.src), [
'/proxy/sticker/show%20love',
`/proxy/sticker/${encodeURIComponent('伊埃斯跳舞')}`
]);
});
test('web chat renders HAR-style Steam image BBCode with proxying, aspect ratio, and lightbox', async () => {
const { renderMessage, lightbox, lightboxImage } = loadWebTestApi();
const fullUrl = 'https://images.example.com/ugc/full/image/';
const thumbnailUrl = `${fullUrl}?imw=512&&ima=fit&imcolor=%23000000`;
const largeUrl = `${fullUrl}?imw=1024&&ima=fit&imcolor=%23000000`;
const message = [
'图片前文 ',
`[img src=${fullUrl} thumbnail_src=${thumbnailUrl} srcset="${largeUrl} 1024w" width=1206 height=1996]`,
`[url=${fullUrl}]${fullUrl}[/url][/img]`,
' 图片后文'
].join('');
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
const shell = rendered.findByClass('bbcode-image-shell');
const button = rendered.findByClass('bbcode-image-button');
const image = rendered.findByClass('bbcode-image');
assert.doesNotMatch(rendered.textContent, /\[\/?(?:img|url)\b/i);
assert.match(rendered.textContent, /图片前文\s+图片后文/);
assert.ok(shell);
assert.ok(button);
assert.ok(image);
assert.equal(image.src, `/proxy/image?url=${encodeURIComponent(thumbnailUrl)}`);
assert.equal(image.srcset, `/proxy/image?url=${encodeURIComponent(largeUrl)} 1024w`);
assert.equal(button.style.aspectRatio, '1206 / 1996');
assert.equal(button.style.width, '253px');
await button.dispatch('click');
assert.equal(lightbox.hidden, false);
assert.equal(lightboxImage.src, `/proxy/image?url=${encodeURIComponent(fullUrl)}`);
await image.dispatch('error');
const fallback = shell.findByTag('a');
assert.ok(fallback);
assert.equal(fallback.href, fullUrl);
assert.equal(fallback.textContent, fullUrl);
});
test('web chat falls back from invalid optional image attributes without rejecting the image', () => {
const { renderMessage } = loadWebTestApi();
const fullUrl = 'https://images.example.com/ugc/full/image/';
const message = `[img src=${fullUrl} thumbnail_src=javascript:alert(1) srcset="javascript:alert(1) 2x" width=0 height=bad][url=${fullUrl}]${fullUrl}[/url][/img]`;
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
const button = rendered.findByClass('bbcode-image-button');
const image = rendered.findByClass('bbcode-image');
assert.ok(button);
assert.ok(image);
assert.equal(image.src, `/proxy/image?url=${encodeURIComponent(fullUrl)}`);
assert.equal(image.srcset, '');
assert.equal(button.style.aspectRatio, undefined);
});
test('web chat accepts the empty image srcset emitted by Steam', () => {
const { renderMessage } = loadWebTestApi();
const fullUrl = 'https://images.example.com/ugc/full/image/';
const message = `[img src=${fullUrl} thumbnail_src=${fullUrl} srcset="" width=704 height=245][url=${fullUrl}]${fullUrl}[/url][/img]`;
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
const image = rendered.findByClass('bbcode-image');
assert.ok(image);
assert.equal(image.srcset, '');
assert.doesNotMatch(rendered.textContent, /\[\/?(?:img|url)\b/i);
});
test('web chat preserves malformed, unsafe, or unsupported Steam BBCode', () => {
const { renderMessage } = loadWebTestApi();
const messages = [
'[url=javascript:alert(1)]危险[/url]',
'[url]not a URL[/url]',
'[url=https://example.com]未闭合',
'[emoticon]bad name[/emoticon]',
'[sticker limit="0"][/sticker]',
'[sticker type="happy" limit="0"]正文[/sticker]',
'[img src=javascript:alert(1)][url=javascript:alert(1)]bad[/url][/img]',
'[img src=https://images.example.com/full][url=https://images.example.com/other]other[/url][/img]',
'[spoiler]未知标签[/spoiler]'
];
for (const message of messages) {
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
assert.equal(rendered.textContent, `Alice${message}`);
assert.equal(rendered.findByClass('bbcode-image'), null);
}
});
test('web chat handles long unclosed supported BBCode without blocking', () => {
const { renderMessage } = loadWebTestApi();
const message = `[img src=https://example.com/${'a'.repeat(100_000)}`;
const startedAt = performance.now();
const rendered = renderMessage({ id: '1', name: 'Alice', type: 'message', message });
assert.ok(performance.now() - startedAt < 250, 'unclosed markup should be handled in linear time');
assert.equal(rendered.textContent, `Alice${message}`);
});