/* eslint-env browser */
const { renderToString } = require('inferno-server');
const DisqusJs = require('./disqusjs');
const Gitalk = require('./gitalk');
const Gitment = require('./gitment');
const Twikoo = require('./twikoo');
const Valine = require('./valine');
const ChangYan = require('./changyan');
const Isso = require('./isso');
const LiveRe = require('./livere');
const Waline = require('./waline');
const Giscus = require('./giscus');
const Utterances = require('./utterances');
const Beaudar = require('./beaudar');
const Artalk = require('./artalk');
const Cusdis = require('./cusdis');
const Disqus = require('./disqus');
const { SchemaLoader } = require('../../core/schema');
/**
* Render a comment component, check that it does not block rendering, and run its inline script.
* Scripts set with innerHTML are not run by the browser, so the inline script is run manually.
*/
function renderAndRun(element) {
const container = document.createElement('div');
container.innerHTML = renderToString(element);
document.body.appendChild(container);
const scripts = Array.from(container.querySelectorAll('script'));
expect(scripts.filter((script) => script.hasAttribute('src'))).toEqual([]);
container.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
expect(link.getAttribute('media')).toBe('print');
});
scripts.forEach((script) => {
// eslint-disable-next-line no-new-func
new Function(script.textContent)();
});
}
/**
* Get the external scripts added to <head>, define the globals they would define, and fire their
* load events in order.
*/
function loadAddedScripts(globals = {}) {
const urls = [];
let script;
while ((script = document.head.querySelector('script:not([data-loaded="true"])'))) {
urls.push(script.getAttribute('src'));
Object.assign(window, globals);
script.dispatchEvent(new Event('load'));
if (script.getAttribute('data-loaded') !== 'true') {
break;
}
}
return urls;
}
let calls;
beforeEach(() => {
document.dispatchEvent(new Event('hexo:page:before-replace'));
document.head.innerHTML = '';
document.body.innerHTML = '';
calls = [];
});
test('DisqusJS', () => {
renderAndRun(
<DisqusJs
shortname="name"
apiKey="key"
path="/post/"
jsUrl="/disqusjs.js"
cssUrl="/disqusjs.css"
/>,
);
expect(calls).toEqual([]);
const urls = loadAddedScripts({
DisqusJS: function (options) {
calls.push(options);
this.render = (container) => expect(container.id).toBe('disqusjs');
this.destroy = () => {};
},
});
expect(urls).toEqual(['/disqusjs.js']);
expect(calls).toEqual([
{ shortname: 'name', apikey: 'key', identifier: '/post/', url: '/post/' },
]);
});
test('DisqusJS keeps configuration and destroys the old widget before PJAX replaces the page', () => {
const instances = [];
window.DisqusJS = function (options) {
this.options = options;
this.render = jest.fn();
this.destroy = jest.fn();
instances.push(this);
};
const helper = { cdn: (name, version, file) => `/cdn/${name}@${version}/${file}` };
const comment = {
shortname: 'name',
api_key: ['key1', 'key2'],
api: 'https://api.example.com/',
admin: 'owner',
admin_label: 'Moderator',
nesting: 6,
};
const element = (page) => (
<DisqusJs.Cacheable comment={comment} helper={helper} page={page} config={{ title: '站点' }} />
);
renderAndRun(
element({
path: 'first/',
permalink: 'https://example.com/first/',
title: '第一页',
disqusId: 'first-id',
}),
);
expect(document.querySelector('link').getAttribute('href')).toBe(
'/cdn/disqusjs@3.2.2/dist/browser/styles/disqusjs.css',
);
expect(loadAddedScripts()).toEqual([
'/cdn/disqusjs@3.2.2/dist/browser/disqusjs.es2015.umd.min.js',
]);
expect(instances[0].options).toEqual({
shortname: 'name',
apikey: ['key1', 'key2'],
api: 'https://api.example.com/',
admin: 'owner',
adminLabel: 'Moderator',
nesting: 6,
siteName: '站点',
identifier: 'first-id',
url: 'https://example.com/first/',
title: '第一页',
});
expect(instances[0].render).toHaveBeenCalledWith(document.getElementById('disqusjs'));
document.dispatchEvent(new Event('hexo:page:before-replace'));
expect(instances[0].destroy).toHaveBeenCalledTimes(1);
document.body.innerHTML = '';
renderAndRun(
element({ path: 'second/', permalink: 'https://example.com/second/', title: '第二页' }),
);
expect(instances).toHaveLength(2);
expect(instances[1].options).toMatchObject({
identifier: 'second/',
url: 'https://example.com/second/',
title: '第二页',
});
expect(document.head.querySelectorAll('script')).toHaveLength(1);
document.dispatchEvent(new Event('hexo:page:before-replace'));
expect(instances[0].destroy).toHaveBeenCalledTimes(1);
expect(instances[1].destroy).toHaveBeenCalledTimes(1);
});
test('DisqusJS does not mount an obsolete page when the SDK finishes loading after navigation', () => {
const construct = jest.fn();
renderAndRun(<DisqusJs shortname="name" apiKey="key" path="first/" jsUrl="/disqusjs.js" />);
document.dispatchEvent(new Event('hexo:page:before-replace'));
document.body.innerHTML = '';
loadAddedScripts({ DisqusJS: construct });
expect(construct).not.toHaveBeenCalled();
});
test('Gitalk', () => {
renderAndRun(
<Gitalk
id="id"
repo="repo"
owner="owner"
admin={['admin']}
clientId="client"
clientSecret="secret"
jsUrl="/gitalk.js"
proxy="https://proxy.example.test"
cssUrl="/gitalk.css"
/>,
);
expect(calls).toEqual([]);
const urls = loadAddedScripts({
Gitalk: function (options) {
this.render = (el) => calls.push([options.id, el]);
this.destroy = () => {};
},
});
expect(urls).toEqual(['/gitalk.js']);
expect(calls).toEqual([['id', 'comment-container']]);
});
test('Gitment', () => {
renderAndRun(
<Gitment id="id" repo="repo" owner="owner" clientId="client" clientSecret="secret" />,
);
const urls = loadAddedScripts({
Gitment: function (options) {
this.render = (el) => calls.push([options.id, el]);
},
});
expect(urls).toEqual(['https://imsun.github.io/gitment/dist/gitment.browser.js']);
expect(calls).toEqual([['id', 'comment-container']]);
});
test('Twikoo', () => {
renderAndRun(<Twikoo envId="env" jsUrl="/twikoo.js" />);
const urls = loadAddedScripts({ twikoo: { init: (options) => calls.push(options) } });
expect(urls).toEqual(['/twikoo.js']);
expect(calls).toEqual([{ envId: 'env', el: document.getElementById('twikoo') }]);
});
test('Valine loads LeanCloud before Valine', () => {
renderAndRun(<Valine appId="id" appKey="key" jsUrl="/valine.js" />);
const urls = loadAddedScripts({
Valine: function (options) {
calls.push(options.appId);
},
});
expect(urls).toEqual(['//cdn.jsdelivr.net/npm/leancloud-storage@3/dist/av-min.js', '/valine.js']);
expect(calls).toEqual(['id']);
});
test('ChangYan', () => {
renderAndRun(<ChangYan appId="id" conf="conf" path="/post/" />);
expect(document.head.querySelector('script').getAttribute('charset')).toBe('utf-8');
const urls = loadAddedScripts({
changyan: { api: { config: (options) => calls.push(options) } },
});
expect(urls).toEqual(['https://changyan.sohu.com/upload/changyan.js']);
expect(calls).toEqual([{ appid: 'id', conf: 'conf' }]);
});
test('LiveRe loads its widget module once for the client ID', () => {
renderAndRun(<LiveRe clientId="client" />);
expect(document.querySelector('livere-comment').getAttribute('client-id')).toBe('client');
const script = document.head.querySelector('script');
expect(script.getAttribute('type')).toBe('module');
expect(loadAddedScripts()).toEqual(['https://www.livere.org/livere-widget.js']);
renderAndRun(<LiveRe clientId="client" />);
expect(document.head.querySelectorAll('script').length).toBe(1);
});
test('LiveRe keeps the legacy City widget for a UID', () => {
const html = renderToString(<LiveRe uid="uid" />);
expect(html).toContain('data-uid="uid"');
expect(html).toContain('https://cdn-city.livere.com/js/embed.dist.js');
expect(html).not.toContain('livere-comment');
expect(renderToString(<LiveRe />)).toContain('client_id');
});
test('Run the inline script again after the library is loaded', () => {
const element = <Twikoo envId="env" jsUrl="/twikoo.js" />;
renderAndRun(element);
loadAddedScripts({ twikoo: { init: (options) => calls.push(options) } });
renderAndRun(element);
expect(document.head.querySelectorAll('script').length).toBe(1);
expect(calls).toEqual([
{ envId: 'env', el: document.getElementById('twikoo') },
{ envId: 'env', el: document.getElementById('twikoo') },
]);
});
test('Isso adds a new script every time', () => {
renderAndRun(<Isso url="isso.example.com" />);
renderAndRun(<Isso url="isso.example.com" />);
const scripts = Array.from(document.head.querySelectorAll('script'));
expect(scripts.length).toBe(2);
scripts.forEach((script) => {
expect(script.getAttribute('src')).toBe('//isso.example.com/js/embed.min.js');
expect(script.getAttribute('data-isso')).toBe('//isso.example.com');
expect(script.async).toBe(true);
});
});
/**
* Render Waline, check that its script is a classic script that PJAX can run, and run the script with
* the dynamic import replaced, since Jest cannot import the URL.
*/
function renderWaline(element) {
const container = document.createElement('div');
container.innerHTML = renderToString(element);
document.body.appendChild(container);
const script = container.querySelector('script');
// PJAX only runs classic scripts, so the script must not be a module
expect(script.hasAttribute('type')).toBe(false);
const code = script.textContent.replace('import(', 'loadWaline(');
const loadWaline = (url) => {
calls.push(['import', url]);
return Promise.resolve({
init: (options) => {
calls.push(['init', options]);
return { update: () => {}, destroy: () => calls.push(['destroy']) };
},
});
};
// eslint-disable-next-line no-new-func
return () => new Function('loadWaline', `return ${code}`)(loadWaline);
}
test('Waline destroys and initializes again when its script runs again', async () => {
const run = renderWaline(
<Waline serverURL="https://waline.example.com" path="'/post/'" jsUrl="/waline.js" />,
);
delete window.__walineInstance;
await run();
await run();
expect(calls.map((call) => call[0])).toEqual(['import', 'init', 'import', 'destroy', 'init']);
expect(calls[0][1]).toBe('/waline.js');
expect(calls[1][1].el).toBe('#waline-thread');
expect(calls[1][1].path).toBe('/post/');
});
describe('Waline options', () => {
const helper = { cdn: (name, version, file) => `/cdn/${name}@${version}/${file}` };
const page = {};
const config = { language: 'en' };
async function getInitOptions(comment) {
delete window.__walineInstance;
await renderWaline(
<Waline.Cacheable comment={comment} helper={helper} page={page} config={config} />,
)();
expect(calls[0]).toEqual(['import', '/cdn/@waline/client@3.16.1/dist/waline.js']);
return calls[1][1];
}
test('Default options', async () => {
expect(await getInitOptions({ server_url: 'https://waline.example.com' })).toEqual({
el: '#waline-thread',
serverURL: 'https://waline.example.com',
path: window.location.pathname,
lang: 'en',
emoji: ['https://cdn.jsdelivr.net/gh/walinejs/emojis/weibo'],
dark: 'html[data-appearance="dark"]',
meta: ['nick', 'mail', 'link'],
requiredMeta: [],
login: 'enable',
pageSize: 10,
pageview: false,
comment: false,
noCopyright: false,
});
});
test('Configured options', async () => {
expect(
await getInitOptions({
server_url: 'https://waline.example.com',
path: "'/custom/'",
lang: 'zh-CN',
locale: { placeholder: 'Hi' },
emoji: ['/emoji'],
dark: 'auto',
meta: ['nick'],
required_meta: ['nick'],
login: 'force',
word_limit: 100,
page_size: 20,
image_uploader: true,
highlighter: true,
tex_renderer: true,
search: true,
pageview: true,
comment: true,
copyright: false,
}),
).toEqual({
el: '#waline-thread',
serverURL: 'https://waline.example.com',
path: '/custom/',
lang: 'zh-CN',
locale: { placeholder: 'Hi' },
emoji: ['/emoji'],
dark: 'auto',
meta: ['nick'],
requiredMeta: ['nick'],
login: 'force',
wordLimit: 100,
pageSize: 20,
imageUploader: false,
highlighter: false,
texRenderer: false,
search: false,
pageview: true,
comment: true,
noCopyright: true,
});
});
test('Keep the features that are not disabled', async () => {
const options = await getInitOptions({
server_url: 'https://waline.example.com',
image_uploader: false,
highlighter: false,
tex_renderer: false,
search: false,
});
expect(Object.keys(options)).not.toContain('imageUploader');
expect(Object.keys(options)).not.toContain('highlighter');
expect(Object.keys(options)).not.toContain('texRenderer');
expect(Object.keys(options)).not.toContain('search');
});
test('Use a path or a JavaScript expression', async () => {
const options = await getInitOptions({
server_url: 'https://waline.example.com',
path: '/posts/hello/',
});
expect(options.path).toBe('/posts/hello/');
calls = [];
expect(
(await getInitOptions({ server_url: 'https://waline.example.com', path: "'/a/' + 'b/'" }))
.path,
).toBe('/a/b/');
});
test('Show an error without the server URL', () => {
const container = document.createElement('div');
container.innerHTML = renderToString(
<Waline.Cacheable comment={{}} helper={helper} page={page} config={config} />,
);
expect(container.querySelector('.notification.is-danger')).not.toBe(null);
expect(container.querySelector('script')).toBe(null);
});
});
describe('Waline dark mode', () => {
afterEach(() => document.documentElement.removeAttribute('data-appearance'));
async function getDark(appearance, props = {}) {
if (appearance) {
document.documentElement.setAttribute('data-appearance', appearance);
}
delete window.__walineInstance;
await renderWaline(
<Waline serverURL="https://waline.example.com" jsUrl="/waline.js" {...props} />,
)();
return calls[1][1].dark;
}
test('Follow the appearance of the page if not set', async () => {
expect(await getDark(null)).toBe('html[data-appearance="dark"]');
expect(await getDark('dark')).toBe('html[data-appearance="dark"]');
calls = [];
expect(await getDark('auto')).toBe('auto');
});
test('Keep the configured value', async () => {
expect(await getDark('auto', { dark: false })).toBe(false);
calls = [];
expect(await getDark('auto', { dark: '.dark' })).toBe('.dark');
});
});
/**
* Render a comment component that adds its client script to a thread element, run its inline script
* with the given appearance, and get the client script from the thread element.
*/
function renderClientScript(element, thread, appearance, prefersDark = false) {
if (appearance) {
document.documentElement.setAttribute('data-appearance', appearance);
}
window.matchMedia = (query) => ({ matches: query.includes('dark') && prefersDark });
jest.useFakeTimers();
renderAndRun(element);
// The client script is added after the inline script has finished
expect(document.querySelector(`${thread} > script[src]`)).toBe(null);
jest.runAllTimers();
jest.useRealTimers();
const script = document.querySelector(`${thread} > script[src]`);
document.head.innerHTML = '';
document.body.innerHTML = '';
document.documentElement.removeAttribute('data-appearance');
delete window.matchMedia;
return script;
}
describe('Giscus', () => {
const props = { repo: 'user/repo', repoId: 'R_1', categoryId: 'C_1' };
const theme = (element, ...args) =>
renderClientScript(element, 'head', ...args).getAttribute('data-theme');
test('Render the giscus element, and add the client script with its attributes to <head>', () => {
const container = document.createElement('div');
container.innerHTML = renderToString(<Giscus {...props} />);
expect(container.querySelector('#giscus-thread.giscus')).not.toBe(null);
const script = renderClientScript(
<Giscus {...props} mapping="specific" term="Welcome" />,
'head',
);
expect(script.getAttribute('src')).toBe('https://giscus.app/client.js');
expect(script.getAttribute('data-repo')).toBe('user/repo');
expect(script.getAttribute('data-repo-id')).toBe('R_1');
expect(script.getAttribute('data-category-id')).toBe('C_1');
expect(script.getAttribute('data-mapping')).toBe('specific');
expect(script.getAttribute('data-term')).toBe('Welcome');
expect(script.getAttribute('crossorigin')).toBe('anonymous');
expect(script.async).toBe(true);
});
test('Choose the theme by the appearance of the page', () => {
expect(theme(<Giscus {...props} />)).toBe('noborder_light');
expect(theme(<Giscus {...props} />, 'dark')).toBe('noborder_dark');
expect(theme(<Giscus {...props} />, 'auto', false)).toBe('noborder_light');
expect(theme(<Giscus {...props} />, 'auto', true)).toBe('noborder_dark');
expect(theme(<Giscus {...props} theme="light" darkTheme="dark_dimmed" />, 'dark')).toBe(
'dark_dimmed',
);
});
test('Always use the custom theme', () => {
expect(theme(<Giscus {...props} theme="custom" customThemeCss="/giscus.css" />, 'dark')).toBe(
'/giscus.css',
);
});
});
describe('Utterances', () => {
const props = { repo: 'user/repo', issueTerm: 'pathname' };
test('Add the client script with its attributes to the thread element', () => {
const script = renderClientScript(<Utterances {...props} />, '#utterances-thread');
expect(script.getAttribute('src')).toBe('https://utteranc.es/client.js');
expect(script.getAttribute('repo')).toBe('user/repo');
expect(script.getAttribute('issue-term')).toBe('pathname');
expect(script.getAttribute('crossorigin')).toBe('anonymous');
});
test('Choose the theme by the appearance of the page', () => {
const theme = (element, ...args) =>
renderClientScript(element, '#utterances-thread', ...args).getAttribute('theme');
expect(theme(<Utterances {...props} />)).toBe('github-light');
expect(theme(<Utterances {...props} />, 'dark')).toBe('github-dark');
expect(
theme(<Utterances {...props} theme="github-light" darkTheme="icy-dark" />, 'auto', true),
).toBe('icy-dark');
});
});
describe('Beaudar', () => {
const props = { repo: 'user/repo', branch: 'main', issueTerm: 'pathname', label: 'comment' };
test('Add the client script with its attributes to the thread element', () => {
const script = renderClientScript(<Beaudar {...props} />, '#beaudar-thread');
expect(script.getAttribute('src')).toBe('https://beaudar.lipk.org/client.js');
expect(script.getAttribute('repo')).toBe('user/repo');
expect(script.getAttribute('branch')).toBe('main');
expect(script.getAttribute('issue-term')).toBe('pathname');
expect(script.getAttribute('label')).toBe('comment');
expect(script.getAttribute('crossorigin')).toBe('anonymous');
});
test('Choose the theme by the appearance of the page', () => {
const theme = (element, ...args) =>
renderClientScript(element, '#beaudar-thread', ...args).getAttribute('theme');
expect(theme(<Beaudar {...props} />)).toBe('github-light');
expect(theme(<Beaudar {...props} />, 'dark')).toBe('github-dark');
expect(theme(<Beaudar {...props} darkTheme="icy-dark" />, 'auto', true)).toBe('icy-dark');
});
test('Show an error without the repository', () => {
const container = document.createElement('div');
container.innerHTML = renderToString(<Beaudar issueTerm="pathname" />);
expect(container.querySelector('.notification.is-danger')).not.toBe(null);
});
});
describe('Artalk', () => {
const props = {
server: 'https://artalk.example.com',
jsUrl: '/artalk.js',
cssUrl: '/artalk.css',
};
const Artalk_ = {
init: (options) => {
calls.push(['init', options]);
const handlers = new Map();
return {
on: (name, handler) => {
if (!handlers.has(name)) handlers.set(name, []);
handlers.get(name).push(handler);
},
update: jest.fn(),
destroy: () => {
calls.push(['destroy']);
(handlers.get('unmounted') || []).forEach((handler) => handler());
},
};
},
};
afterEach(() => {
if (window.__artalkInstance) window.__artalkInstance.destroy();
delete window.__artalkInstance;
document.documentElement.removeAttribute('data-appearance');
});
test('Load Artalk and initialize it, and destroy the previous instance', () => {
const element = <Artalk {...props} site="Blog" pageTitle="Post" locale="en" />;
renderAndRun(element);
expect(calls).toEqual([]);
expect(loadAddedScripts({ Artalk: Artalk_ })).toEqual(['/artalk.js']);
expect(calls).toEqual([
[
'init',
{
el: '#artalk-thread',
server: 'https://artalk.example.com',
site: 'Blog',
pageTitle: 'Post',
locale: 'en',
darkMode: false,
},
],
]);
// Run again, e.g., by PJAX
renderAndRun(element);
expect(calls.map((call) => call[0])).toEqual(['init', 'destroy', 'init']);
});
test('Follow the appearance of the page if dark_mode is not set', () => {
const darkMode = (appearance, extra = {}) => {
calls = [];
document.documentElement.setAttribute('data-appearance', appearance);
window.Artalk = Artalk_;
renderAndRun(<Artalk {...props} {...extra} />);
loadAddedScripts({ Artalk: Artalk_ });
return calls.find((call) => call[0] === 'init')[1].darkMode;
};
expect(darkMode('dark')).toBe(true);
expect(darkMode('auto')).toBe('auto');
expect(darkMode('dark', { darkMode: false })).toBe(false);
});
test('Show an error without the server', () => {
const container = document.createElement('div');
container.innerHTML = renderToString(<Artalk jsUrl="/artalk.js" />);
expect(container.querySelector('.notification.is-danger')).not.toBe(null);
});
test('Disconnect the appearance observer when the instance is unmounted', async () => {
renderAndRun(<Artalk {...props} />);
loadAddedScripts({ Artalk: Artalk_ });
const instance = window.__artalkInstance;
document.documentElement.setAttribute('data-appearance', 'dark');
await Promise.resolve();
expect(instance.update).toHaveBeenLastCalledWith({ darkMode: true });
instance.destroy();
delete window.__artalkInstance;
instance.update.mockClear();
document.documentElement.setAttribute('data-appearance', 'light');
await Promise.resolve();
expect(instance.update).not.toHaveBeenCalled();
});
});
describe('Cusdis', () => {
const props = { appId: 'app', pageId: '/post/', pageUrl: 'https://example.com/post/' };
afterEach(() => {
delete window.renderCusdis;
document.documentElement.removeAttribute('data-appearance');
});
test('Load the Cusdis script, which renders the thread element', () => {
renderAndRun(<Cusdis {...props} lang="zh-cn" />);
const thread = document.getElementById('cusdis_thread');
expect(thread.getAttribute('data-host')).toBe('https://cusdis.com');
expect(thread.getAttribute('data-app-id')).toBe('app');
expect(thread.getAttribute('data-page-id')).toBe('/post/');
expect(thread.getAttribute('data-page-url')).toBe('https://example.com/post/');
expect(thread.getAttribute('data-theme')).toBe('light');
expect(loadAddedScripts()).toEqual([
'https://cusdis.com/js/widget/lang/zh-cn.js',
'https://cusdis.com/js/cusdis.es.js',
]);
});
test('Render the thread element directly when the script is already loaded', () => {
window.renderCusdis = (element) => calls.push(element.id);
renderAndRun(<Cusdis {...props} host="https://cusdis.example.com" />);
expect(calls).toEqual(['cusdis_thread']);
expect(document.head.querySelectorAll('script').length).toBe(0);
});
test('Follow the appearance of the page if theme is not set', () => {
const theme = (appearance, extra = {}) => {
document.body.innerHTML = '';
document.documentElement.setAttribute('data-appearance', appearance);
renderAndRun(<Cusdis {...props} {...extra} />);
return document.getElementById('cusdis_thread').getAttribute('data-theme');
};
expect(theme('dark')).toBe('dark');
expect(theme('auto')).toBe('auto');
expect(theme('auto', { theme: 'light' })).toBe('light');
});
});
test('Schemas of the new comment plugins', () => {
const validate = (name, config) => {
const def = require(`../../schema/comment/${name}.json`);
return SchemaLoader.load(def).getSchema(def.$id).validate(config);
};
expect(validate('artalk', { type: 'artalk', server: 'https://artalk.example.com' })).toBe(true);
expect(validate('artalk', { type: 'artalk' })).not.toBe(true);
expect(validate('cusdis', { type: 'cusdis', app_id: 'app' })).toBe(true);
expect(validate('cusdis', { type: 'cusdis', app_id: 'app', theme: 'blue' })).not.toBe(true);
expect(validate('livere', { type: 'livere', client_id: 'client' })).toBe(true);
expect(validate('livere', { type: 'livere', uid: 'uid' })).toBe(true);
expect(validate('livere', { type: 'livere' })).not.toBe(true);
expect(validate('beaudar', { type: 'beaudar', repo: 'user/repo', issue_term: 'pathname' })).toBe(
true,
);
expect(validate('beaudar', { type: 'beaudar', repo: 'user/repo' })).not.toBe(true);
});
describe('Configuration of the comment plugins', () => {
const helper = {
cdn: (name, version, file) => `/cdn/${name}@${version}/${file}`,
url_for: (url) => `/blog/${url}`,
};
const page = { path: 'post/', permalink: 'https://example.com/blog/post/', title: 'Post' };
function renderCacheable(Component, comment) {
const container = document.createElement('div');
container.innerHTML = renderToString(
<Component.Cacheable comment={comment} helper={helper} page={page} config={{}} />,
);
return container;
}
function getError(Component, props) {
const container = document.createElement('div');
container.innerHTML = renderToString(<Component {...props} />);
const error = container.querySelector('.notification.is-danger');
return error ? error.textContent : null;
}
test('Giscus', () => {
const container = renderCacheable(Giscus, {
repo: 'user/repo',
repoId: 'R_1',
category: 'General',
categoryId: 'C_1',
mapping: 'title',
strict: true,
reactionsEnabled: true,
emitMetadata: true,
inputPosition: 'bottom',
theme: 'light',
darkTheme: 'dark',
lang: 'zh-CN',
lazy: true,
});
const js = container.querySelector('script').textContent;
for (const value of [
'"data-category":"General"',
'"data-mapping":"title"',
'"data-strict":1',
'"data-reactions-enabled":1',
'"data-emit-metadata":1',
'"data-input-position":"bottom"',
'"data-lang":"zh-CN"',
'"data-loading":"lazy"',
'"dark" : "light"',
]) {
expect(js).toContain(value);
}
const props = { repo: 'user/repo', repoId: 'R_1', categoryId: 'C_1' };
expect(getError(Giscus, { repo: 'user/repo' })).toContain('repoId');
expect(getError(Giscus, { ...props, mapping: 'number' })).toContain('term');
expect(getError(Giscus, { ...props, theme: 'custom' })).toContain('customThemeCss');
});
test('Utterances and Beaudar', () => {
for (const [Component, thread] of [
[Utterances, '#utterances-thread'],
[Beaudar, '#beaudar-thread'],
]) {
const comment = {
repo: 'user/repo',
issue_number: 1,
label: 'comment',
dark_theme: 'icy-dark',
};
const js = renderCacheable(Component, comment).querySelector('script').textContent;
expect(js).toContain(`document.querySelector("${thread}")`);
expect(js).toContain('"issue-number":1');
expect(js).toContain('"label":"comment"');
expect(js).toContain('"icy-dark"');
expect(getError(Component, { repo: 'user/repo' })).toContain('issue_term');
}
});
test('Artalk', () => {
const container = renderCacheable(Artalk, {
server: 'https://artalk.example.com',
site: 'Blog',
page_key: '/post/',
locale: 'en',
dark_mode: 'auto',
});
const js = container.querySelector('script').textContent;
expect(js).toContain('"/cdn/artalk@2.10.0/dist/Artalk.js"');
expect(js).toContain('pageKey: "/post/"');
expect(js).toContain('pageTitle: "Post"');
expect(js).toContain('darkMode: "auto"');
expect(container.querySelector('link').getAttribute('href')).toBe(
'/cdn/artalk@2.10.0/dist/Artalk.css',
);
});
test('Cusdis', () => {
const thread = renderCacheable(Cusdis, {
app_id: 'app',
host: 'https://cusdis.example.com/',
theme: 'dark',
}).querySelector('#cusdis_thread');
expect(thread.getAttribute('data-host')).toBe('https://cusdis.example.com');
expect(thread.getAttribute('data-page-id')).toBe('/blog/post/');
expect(thread.getAttribute('data-page-url')).toBe('https://example.com/blog/post/');
expect(thread.getAttribute('data-page-title')).toBe('Post');
expect(getError(Cusdis, {})).toContain('app_id');
});
});
describe('Page values in the inline scripts', () => {
const helper = { cdn: (name, version, file) => `/cdn/${name}@${version}/${file}` };
const title = 'Why </script><img src=x onerror=alert(1)> breaks';
const permalink = "https://example.com/2024/it's-alive/";
const page = { title, path: "2024/it's-alive/", permalink };
test.each([
[
'Artalk',
<Artalk.Cacheable
comment={{ server: 'https://artalk.example.com' }}
helper={helper}
page={page}
/>,
'Why \\u003c/script>',
],
[
'DisqusJS',
<DisqusJs.Cacheable
comment={{ shortname: "it's", api_key: 'key' }}
helper={helper}
page={page}
config={{ title }}
/>,
],
['Disqus', <Disqus.Cacheable comment={{ shortname: 'name' }} page={page} />],
])('%s', (name, element, kept = "it's") => {
const container = document.createElement('div');
container.innerHTML = renderToString(element);
expect(container.querySelector('img')).toBe(null);
const scripts = container.querySelectorAll('script');
expect(scripts.length).toBe(1);
// The script parses, and keeps the values
// eslint-disable-next-line no-new-func
expect(() => new Function(scripts[0].textContent)).not.toThrow();
expect(scripts[0].textContent).toContain(kept);
});
});
describe('Options and missing settings', () => {
const helper = { cdn: (name, version, file) => `/cdn/${name}@${version}/${file}` };
const gitalk = {
repo: 'r',
owner: 'o',
admin: ['o'],
client_id: 'i',
proxy: 'https://proxy.example.test',
};
function render(element) {
const container = document.createElement('div');
container.innerHTML = renderToString(element);
return container;
}
test('Gitalk hotkeys are enabled unless disabled', () => {
const page = { path: 'post/' };
const script = (comment) =>
render(<Gitalk.Cacheable comment={comment} helper={helper} page={page} />).querySelector(
'script',
).textContent;
expect(script(gitalk)).toContain('enableHotKey: true');
expect(script({ ...gitalk, enable_hotkey: false })).toContain('enableHotKey: false');
});
test('Isso URL with or without the protocol', () => {
for (const url of ['isso.example.com', 'https://isso.example.com/', '//isso.example.com']) {
renderAndRun(<Isso url={url} />);
}
Array.from(document.head.querySelectorAll('script')).forEach((script) => {
expect(script.getAttribute('src')).toBe('//isso.example.com/js/embed.min.js');
expect(script.getAttribute('data-isso')).toBe('//isso.example.com');
});
});
test('Show an error without the Twikoo environment ID or the DisqusJS API key', () => {
expect(render(<Twikoo jsUrl="/twikoo.js" />).querySelector('.is-danger')).not.toBe(null);
expect(
render(<DisqusJs shortname="name" jsUrl="/disqusjs.js" />).querySelector('.is-danger'),
).not.toBe(null);
});
test('Giscus term in the schema', () => {
const def = require('../../schema/comment/giscus.json');
const schema = SchemaLoader.load(def).getSchema(def.$id);
const config = { type: 'giscus', repo: 'a/b', repoId: 'r', categoryId: 'c', mapping: 'number' };
expect(schema.validate({ ...config, term: 42 })).toBe(true);
expect(schema.validate({ ...config, term: 'term' })).toBe(true);
});
});