mirror of
https://github.com/rustdesk/doc.rustdesk.com.git
synced 2026-10-26 22:10:43 +00:00
seo: localize blog metadata and FAQ, expand llms.txt, guard blog facts in tests
Open Graph locale, JSON-LD language and the blog breadcrumb follow the post's language, the FAQ heading is translated, and time elements use ISO dates. llms.txt and llms-full.txt list core capabilities next to the licensing and deployment boundaries. New tests keep FAQ answers plain text and in step with the English, catch repeated paragraphs, overlong meta descriptions and "prorated" upgrade wording, and a build verifier checks the rendered blog. IndexNow no longer reports success when a sitemap cannot be read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f9ab65ef7d
commit
6eb8e29990
17 files changed
+733
-512
No files matched your search
@@ -1,392 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
import yaml from 'js-yaml';
|
||||
import slugify from 'limax';
|
||||
|
||||
const root = new URL('..', import.meta.url).pathname;
|
||||
const postsDir = join(root, 'src/content/post/en');
|
||||
const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||
const readPost = (name) => readFileSync(join(postsDir, name), 'utf8');
|
||||
import { localizedPath, validatePostTranslations } from '../src/utils/blog-i18n.mjs';
|
||||
|
||||
test('uses a verified organizational byline for the reviewed publication batch', () => {
|
||||
const affected = readdirSync(postsDir)
|
||||
.filter((name) => name.endsWith('.md') || name.endsWith('.mdx'))
|
||||
.filter((name) => readPost(name).includes('author: Hoabing Ziu, CTO'));
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const postsDir = join(root, 'src/content/post');
|
||||
const locales = ['en', 'de', 'es', 'fr', 'it', 'ja', 'ko', 'pt', 'zh-cn', 'zh-tw', 'ar'];
|
||||
|
||||
assert.deepEqual(affected, []);
|
||||
});
|
||||
const posts = locales.flatMap((lang) =>
|
||||
readdirSync(join(postsDir, lang))
|
||||
.filter((name) => /\.(md|mdx)$/.test(name))
|
||||
.map((name) => {
|
||||
const id = lang + '/' + name;
|
||||
const source = readFileSync(join(postsDir, id), 'utf8');
|
||||
const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
assert.ok(frontmatter, id + ' needs YAML frontmatter');
|
||||
const data = yaml.load(frontmatter[1]);
|
||||
assert.ok(data && typeof data === 'object', id + ' needs an object frontmatter');
|
||||
return {
|
||||
...data,
|
||||
id,
|
||||
slug: slugify(data.slug || basename(name).replace(/\.(md|mdx)$/, '')),
|
||||
source,
|
||||
};
|
||||
})
|
||||
.filter((post) => !post.draft)
|
||||
);
|
||||
|
||||
test('renders post FAQ data visibly and attributes RustDesk Team to the organization schema', () => {
|
||||
const route = read('src/pages/[...blog]/index.astro');
|
||||
const faqComponent = read('src/components/blog/BlogFaq.astro');
|
||||
const structuredData = read('src/components/common/StructuredData.astro');
|
||||
|
||||
assert.match(route, /<BlogFaq items=\{post\.faq\} \/>/);
|
||||
assert.match(faqComponent, /<h2[^>]*>Frequently asked questions<\/h2>/);
|
||||
assert.match(faqComponent, /item\.question/);
|
||||
assert.match(faqComponent, /item\.answer/);
|
||||
assert.match(structuredData, /article\.author === 'RustDesk Team'/);
|
||||
assert.match(structuredData, /author: articleAuthorSchema/);
|
||||
});
|
||||
|
||||
test('requires a protected path before entering initial web-console credentials', () => {
|
||||
const article = readPost('rustdesk-web-console-custom-client-generator-port-21114.md');
|
||||
|
||||
assert.match(article, /do not enter credentials over plain HTTP on a public or untrusted network\./i);
|
||||
assert.match(article, /localhost, an SSH tunnel, or a trusted private network/);
|
||||
});
|
||||
|
||||
test('limits no-inbound-port and iOS host claims in the RDP comparison', () => {
|
||||
const article = readPost('rustdesk-vs-rdp.md');
|
||||
|
||||
assert.match(article, /endpoint clients initiate outbound connections/);
|
||||
assert.match(article, /self-hosted server still accepts inbound connections/);
|
||||
assert.match(article, /iOS acts as a controller only/);
|
||||
assert.doesNotMatch(article, /controlling and being controlled/);
|
||||
assert.doesNotMatch(article, /Host platforms\s*\|[^\n]*iOS/);
|
||||
});
|
||||
|
||||
test('does not claim that self-hosted RustDesk exposes nothing inbound', () => {
|
||||
const affected = readdirSync(postsDir)
|
||||
.filter((name) => name.endsWith('.md') || name.endsWith('.mdx'))
|
||||
.filter((name) => /nothing inbound is exposed/i.test(readPost(name)));
|
||||
|
||||
assert.deepEqual(affected, []);
|
||||
});
|
||||
|
||||
test('uses protocol-specific RustDesk server port guidance', () => {
|
||||
const article = readPost('rustdesk-connected-waiting-for-image.md');
|
||||
|
||||
assert.doesNotMatch(article, /TCP\/UDP 21115-21119/);
|
||||
assert.match(article, /TCP 21115-21117 and UDP 21116/);
|
||||
assert.match(article, /TCP 21118-21119 only if you use WebSocket clients/);
|
||||
});
|
||||
|
||||
test('states the evidence and limits behind the large-fleet capacity example', () => {
|
||||
const article = readPost('rustdesk-scale-50000-200000-devices.md');
|
||||
|
||||
assert.match(article, /more than two million online endpoints/);
|
||||
assert.match(article, /12-core CPU and 32 GB of RAM/);
|
||||
assert.match(article, /not two million simultaneous remote-control sessions/);
|
||||
assert.match(article, /production observation, not a reproducible Server Pro benchmark/);
|
||||
assert.doesNotMatch(article, /one million|million-plus/i);
|
||||
});
|
||||
|
||||
test('lists first-party ScreenConnect sources promised by the comparison', () => {
|
||||
const article = readPost('rustdesk-vs-screenconnect.md');
|
||||
|
||||
assert.match(article, /## Sources/);
|
||||
assert.match(article, /https:\/\/www\.screenconnect\.com\/pricing/);
|
||||
assert.match(
|
||||
article,
|
||||
/https:\/\/docs\.connectwise\.com\/ScreenConnect_Documentation\/Get_started\/Host_client\/Host_client_requirements/
|
||||
);
|
||||
assert.match(
|
||||
article,
|
||||
/https:\/\/www\.connectwise\.com\/company\/trust\/security-bulletins\/connectwise-screenconnect-23\.9\.8/
|
||||
);
|
||||
});
|
||||
|
||||
test('opens only the RustDesk ports required by each deployed feature', () => {
|
||||
const article = readPost('why-self-host-remote-desktop-software.md');
|
||||
|
||||
assert.doesNotMatch(article, /default ports are \*\*21114–21119\*\*/);
|
||||
assert.match(article, /TCP 21115-21117 and UDP 21116/);
|
||||
assert.match(article, /expose only TCP 443 publicly/);
|
||||
assert.match(article, /keep 21114 and 21118-21119 internal/);
|
||||
});
|
||||
|
||||
test('limits the no-code scam advice to the typical social-engineering flow', () => {
|
||||
const article = readPost('avoid-remote-desktop-scams.md');
|
||||
|
||||
assert.doesNotMatch(article, /they cannot simply connect/);
|
||||
assert.match(article, /typical social-engineering flow/);
|
||||
assert.match(
|
||||
article,
|
||||
/does not rule out existing unattended access, stolen credentials, malware, or exposed services/
|
||||
);
|
||||
});
|
||||
|
||||
test('distinguishes Splashtop cloud plans from Splashtop On-Prem', () => {
|
||||
const article = readPost('rustdesk-vs-splashtop.md');
|
||||
|
||||
assert.match(article, /managed SaaS plans and a \*\*separately licensed On-Prem\*\* product/);
|
||||
assert.match(article, /https:\/\/www\.splashtop\.com\/products\/on-prem/);
|
||||
assert.match(article, /customer-operated Splashtop (?:On-Prem )?Gateway/i);
|
||||
assert.doesNotMatch(article, /It remains a managed cloud service/);
|
||||
assert.doesNotMatch(article, /Splashtop is a cloud service/);
|
||||
});
|
||||
|
||||
test('consolidates Splashtop comparisons without erasing the On-Prem product', () => {
|
||||
const removedSlug = 'teamviewer-vs-anydesk-vs-splashtop';
|
||||
const teamViewerComparison = readPost('teamviewer-vs-splashtop.md');
|
||||
const rustDeskComparison = readPost('rustdesk-vs-splashtop.md');
|
||||
const mspComparison = readPost('teamviewer-vs-anydesk-for-msps.md');
|
||||
const netlify = read('netlify.toml');
|
||||
const vercel = read('vercel.json');
|
||||
|
||||
assert.equal(existsSync(join(postsDir, `${removedSlug}.md`)), false);
|
||||
assert.equal(existsSync(join(root, `src/assets/images/blog/${removedSlug}-og.png`)), false);
|
||||
assert.doesNotMatch(mspComparison, new RegExp(`/blog/${removedSlug}`));
|
||||
assert.match(
|
||||
netlify,
|
||||
new RegExp(`from = "/blog/${removedSlug}"[\\s\\S]*?to = "/blog/teamviewer-vs-splashtop"[\\s\\S]*?status = 301`)
|
||||
);
|
||||
assert.match(
|
||||
vercel,
|
||||
new RegExp(
|
||||
`"source": "/blog/${removedSlug}"[\\s\\S]*?"destination": "/blog/teamviewer-vs-splashtop"[\\s\\S]*?"permanent": true`
|
||||
)
|
||||
);
|
||||
|
||||
for (const article of [teamViewerComparison, rustDeskComparison]) {
|
||||
assert.match(article, /separately licensed On-Prem/i);
|
||||
assert.match(article, /Splashtop (?:On-Prem )?Gateway/i);
|
||||
assert.doesNotMatch(article, /Splashtop[^\n]*cloud only/i);
|
||||
assert.doesNotMatch(article, /(?:almost|hardly|nearly) (?:no one|nobody)|few customers use/i);
|
||||
test('published content can be loaded with valid dates and nonempty FAQ entries', () => {
|
||||
assert.ok(posts.length > 0, 'the publication batch must not be empty');
|
||||
for (const post of posts) {
|
||||
assert.ok(typeof post.title === 'string' && post.title.trim(), post.id + ' needs a title');
|
||||
assert.ok(
|
||||
typeof post.translationKey === 'string' && post.translationKey.trim(),
|
||||
post.id + ' needs a translation key'
|
||||
);
|
||||
assert.ok(
|
||||
post.publishDate instanceof Date && !Number.isNaN(post.publishDate.valueOf()),
|
||||
post.id + ' needs a publication date'
|
||||
);
|
||||
if (post.updateDate) {
|
||||
assert.ok(
|
||||
post.updateDate instanceof Date && !Number.isNaN(post.updateDate.valueOf()),
|
||||
post.id + ' has an invalid update date'
|
||||
);
|
||||
assert.ok(post.updateDate >= post.publishDate, post.id + ' update cannot precede publication');
|
||||
}
|
||||
for (const item of post.faq || []) {
|
||||
assert.ok(typeof item.question === 'string' && item.question.trim(), post.id + ' has an empty FAQ question');
|
||||
assert.ok(typeof item.answer === 'string' && item.answer.trim(), post.id + ' has an empty FAQ answer');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('positions the Splashtop alternative page as an evidence-bounded IT switching guide', () => {
|
||||
const article = readPost('rustdesk-vs-splashtop.md');
|
||||
|
||||
assert.match(article, /title: 'Self-Hosted Splashtop Alternative/);
|
||||
assert.match(article, /excerpt: 'A self-hosted Splashtop alternative evaluation guide/);
|
||||
assert.match(article, /## Why IT teams evaluate alternatives to Splashtop/);
|
||||
assert.match(article, /do not establish how common/i);
|
||||
assert.match(article, /integration-specific/i);
|
||||
assert.match(article, /remote audio/i);
|
||||
assert.match(article, /monitor mapping/i);
|
||||
assert.match(article, /parallel pilot/i);
|
||||
assert.match(article, /rollback/i);
|
||||
assert.doesNotMatch(article, /72 (?:signals|customers|emails)|3\/72/);
|
||||
assert.doesNotMatch(article, /Kent Tec|Kimray|KinderCare|Liberty Tax|WaliaTech/);
|
||||
assert.doesNotMatch(article, /£50|£200|\$8K|\$8000/);
|
||||
test('published translations satisfy the production locale and uniqueness rules', () => {
|
||||
assert.doesNotThrow(() => validatePostTranslations(posts, locales));
|
||||
});
|
||||
|
||||
test('describes the exploited ScreenConnect vulnerability without calling it a ConnectWise hack', () => {
|
||||
const article = readPost('best-screenconnect-alternative-msps.md');
|
||||
|
||||
assert.doesNotMatch(article, /ConnectWise hack|remote-access vendor gets breached/i);
|
||||
assert.match(article, /exploitation of CVE-2024-1709 in affected ScreenConnect servers/);
|
||||
});
|
||||
|
||||
test('does not impose a personal-use-only license on Chrome Remote Desktop', () => {
|
||||
const article = readPost('best-free-remote-desktop-software.md');
|
||||
|
||||
assert.doesNotMatch(
|
||||
article,
|
||||
/Chrome Remote Desktop is free but licensed for personal use|terms center on personal use/
|
||||
);
|
||||
assert.match(article, /Google documents enterprise administration policies/);
|
||||
assert.match(article, /https:\/\/support\.google\.com\/chrome\/a\/answer\/2799701/);
|
||||
});
|
||||
|
||||
test('separates Chrome Remote Desktop signaling from direct and relayed session traffic', () => {
|
||||
const article = readPost('chrome-remote-desktop-alternative.md');
|
||||
|
||||
assert.doesNotMatch(article, /every session runs through Google/i);
|
||||
assert.match(article, /initially negotiated through Google services/);
|
||||
assert.match(article, /Direct, STUN, or TURN\/relay/);
|
||||
assert.match(article, /https:\/\/support\.google\.com\/chrome\/a\/answer\/16364503/);
|
||||
});
|
||||
|
||||
test('does not lean on a competitor blog for the Chrome Remote Desktop limitations section', () => {
|
||||
const article = readPost('chrome-remote-desktop-alternative.md');
|
||||
|
||||
assert.doesNotMatch(article, /helpwire\.app\/blog\/chrome-remote-desktop/i);
|
||||
assert.match(article, /https:\/\/support\.google\.com\/chrome\/answer\/1649523/);
|
||||
assert.match(article, /https:\/\/support\.google\.com\/chrome\/a\/answer\/16364503/);
|
||||
});
|
||||
|
||||
test('does not present RustDesk iOS as an unattended host in the Chrome Remote Desktop comparison', () => {
|
||||
const article = readPost('chrome-remote-desktop-alternative.md');
|
||||
|
||||
assert.doesNotMatch(
|
||||
article,
|
||||
/Real file transfer and permanent-password unattended access come standard, across Windows, macOS, Linux, Android, and iOS\./
|
||||
);
|
||||
assert.match(article, /the iOS app is controller-only/);
|
||||
});
|
||||
|
||||
test('does not explain AnyDesk commercial-use detection through mandatory cloud relay', () => {
|
||||
const article = readPost('anydesk-commercial-use-detected.md');
|
||||
|
||||
assert.doesNotMatch(article, /every session runs through _their_ cloud/);
|
||||
assert.match(article, /AnyDesk supports direct client-to-client connections/);
|
||||
assert.match(article, /https:\/\/support\.anydesk\.com\/docs\/settings/);
|
||||
});
|
||||
|
||||
test('labels the public-server capacity number as a non-audited point-in-time internal observation', () => {
|
||||
const article = readPost('rustdesk-scale-50000-200000-devices.md');
|
||||
|
||||
assert.match(article, /point-in-time internal production observation/);
|
||||
assert.match(article, /not independently audited/);
|
||||
assert.match(article, /no public monitoring dashboard/);
|
||||
assert.doesNotMatch(article, /has operated stably/);
|
||||
});
|
||||
|
||||
test('sources AnyDesk plan limits from the current official pricing page', () => {
|
||||
const article = readPost('rustdesk-vs-anydesk.md');
|
||||
|
||||
assert.match(article, /https:\/\/anydesk\.com\/en\/pricing/);
|
||||
assert.match(article, /checked July 7, 2026/);
|
||||
assert.doesNotMatch(article, /third-party pricing trackers|Independent trackers/);
|
||||
});
|
||||
|
||||
test('splits RustDesk iOS controller support from host features in the free-software roundup', () => {
|
||||
const article = readPost('best-free-remote-desktop-software.md');
|
||||
|
||||
assert.doesNotMatch(
|
||||
article,
|
||||
/cross-platform \(Windows, macOS, Linux, Android, iOS\), with file transfer and unattended access/i
|
||||
);
|
||||
assert.match(article, /Windows, macOS, Linux, and Android hosts; iOS is a controller app/);
|
||||
});
|
||||
|
||||
test('sources and bounds TeamViewer and Splashtop security claims', () => {
|
||||
const article = readPost('teamviewer-vs-splashtop.md');
|
||||
|
||||
assert.match(article, /https:\/\/www\.splashtop\.com\/security/);
|
||||
assert.match(article, /https:\/\/www\.splashtop\.com\/press\/splashtop-achieves-iso-iec-27001-2022-certification/);
|
||||
assert.match(article, /https:\/\/www\.teamviewer\.com\/en\/resources\/trust-center/);
|
||||
assert.match(article, /tv-2024-1005/);
|
||||
assert.doesNotMatch(article, /no publicly disclosed breach/);
|
||||
});
|
||||
|
||||
test('removes the unsupported claim that AnyDesk detection became stricter in 2025', () => {
|
||||
const article = readPost('anydesk-commercial-use-detected.md');
|
||||
|
||||
assert.doesNotMatch(article, /got stricter in 2025|became stricter in 2025/i);
|
||||
});
|
||||
|
||||
test('uses AnyDesk official pricing instead of unnamed trackers in the MSP FAQ', () => {
|
||||
const article = readPost('teamviewer-vs-anydesk-for-msps.md');
|
||||
|
||||
assert.doesNotMatch(article, /Multiple trackers|multiple trackers reporting/i);
|
||||
assert.match(article, /https:\/\/anydesk\.com\/en\/pricing/);
|
||||
assert.match(article, /official pricing page, checked July 7, 2026/);
|
||||
});
|
||||
|
||||
test('explains why RustDesk is unavailable on Google Play and gives current Android sources', () => {
|
||||
const article = readPost('rustdesk-remote-control-android-ios.md');
|
||||
|
||||
assert.doesNotMatch(article, /install (?:the app )?from Google Play/i);
|
||||
assert.doesNotMatch(article, /Android client is on Google Play/i);
|
||||
assert.match(article, /not currently distributed through Google Play/i);
|
||||
assert.match(article, /\/blog\/rustdesk-and-remote-access-scams/);
|
||||
assert.match(article, /github\.com\/rustdesk\/rustdesk\/releases/);
|
||||
assert.match(article, /f-droid\.org\/packages\/com\.carriez\.flutter_hbb/);
|
||||
});
|
||||
|
||||
test('documents RustDesk anti-scam actions and controlled-device defenses', () => {
|
||||
const article = readPost('rustdesk-and-remote-access-scams.md');
|
||||
|
||||
assert.match(article, /x\.com\/rustdesk\/status\/1698372220379349421/);
|
||||
assert.match(article, /github\.com\/rustdesk\/rustdesk\/discussions\/5660/);
|
||||
assert.match(article, /Login-required-for-public-server/);
|
||||
assert.match(article, /strong, unique permanent password/i);
|
||||
assert.match(article, /controlled device[^\n]*2FA|2FA[^\n]*controlled device/i);
|
||||
assert.match(article, /IP allowlist/i);
|
||||
assert.match(article, /\/32/);
|
||||
assert.match(article, /\/24/);
|
||||
assert.match(article, /\/128/);
|
||||
assert.match(article, /\/64/);
|
||||
assert.doesNotMatch(article, /Server Pro (?:account|web-console) 2FA/i);
|
||||
});
|
||||
|
||||
test('uses Server Pro Access Control rather than address-book visibility for authorization', () => {
|
||||
const article = readPost('rustdesk-per-user-access-control-device-groups-shared-address-book.md');
|
||||
|
||||
assert.match(article, /https:\/\/rustdesk\.com\/docs\/en\/self-host\/rustdesk-server-pro\/permissions\//);
|
||||
assert.match(article, /address book[^\n]*(?:visibility|convenience)[^\n]*not[^\n]*authorization/i);
|
||||
assert.match(article, /user assignments, device groups, and cross-group access settings/i);
|
||||
assert.match(article, /permissions are cumulative/i);
|
||||
assert.match(article, /known device ID/i);
|
||||
assert.doesNotMatch(article, /working approach today[^\n]*shared address book/i);
|
||||
assert.doesNotMatch(article, /per-user access control today[^\n]*shared address book/i);
|
||||
});
|
||||
|
||||
test('points license-management posts at the self-service portal with a support fallback', () => {
|
||||
const portalPosts = [
|
||||
'rustdesk-not-connecting-troubleshooting.md',
|
||||
'upgrade-rustdesk-license-mid-subscription.md',
|
||||
'rustdesk-custom-quote-minimum-users-invoice-fees.md',
|
||||
'rustdesk-pro-license-cost-how-to-pay.md',
|
||||
];
|
||||
for (const name of portalPosts) {
|
||||
const article = readPost(name);
|
||||
assert.match(article, /rustdesk\.com\/self-host\/account/, `${name} should link the self-service license portal`);
|
||||
assert.match(article, /support@rustdesk\.com/, `${name} should give the support@ fallback for a forgotten checkout email`);
|
||||
test('published posts link to existing blog articles in the requested locale', () => {
|
||||
const articlePaths = new Set(posts.map((post) => localizedPath(post.lang, 'blog/' + post.slug)));
|
||||
for (const post of posts) {
|
||||
const body = post.source.replace(/~~~[\s\S]*?~~~|```[\s\S]*?```/g, '');
|
||||
for (const match of body.matchAll(/\]\((\/(?:[a-z-]+\/)?blog\/[^)\s]+)\)/g)) {
|
||||
const path = decodeURI(match[1].split(/[?#]/)[0]).replace(/\/$/, '');
|
||||
assert.ok(articlePaths.has(path), post.id + ' links to an unpublished blog route: ' + match[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('states the air-gapped license-validation grace window', () => {
|
||||
const article = readPost('rustdesk-server-pro-offline-air-gapped.md');
|
||||
|
||||
assert.match(article, /seven days/i);
|
||||
assert.match(article, /grace/i);
|
||||
});
|
||||
|
||||
test('adds the missing competitor migration drivers to the comparison posts', () => {
|
||||
const screenconnect = readPost('rustdesk-vs-screenconnect.md');
|
||||
assert.match(screenconnect, /code-signing/i);
|
||||
assert.match(screenconnect, /July 7, 2025/);
|
||||
|
||||
const teamviewer = readPost('rustdesk-vs-teamviewer.md');
|
||||
assert.match(teamviewer, /perpetual license/i);
|
||||
assert.match(teamviewer, /subscription-only/i);
|
||||
|
||||
const logmein = readPost('rustdesk-vs-logmein.md');
|
||||
assert.match(logmein, /LDAP\/Active Directory/);
|
||||
assert.match(logmein, /single sign-on via OIDC/i);
|
||||
});
|
||||
|
||||
test('answers the enterprise mass-deployment and REST API questions', () => {
|
||||
const article = readPost('rustdesk-for-enterprise.md');
|
||||
|
||||
assert.match(article, /MSI/);
|
||||
assert.match(article, /GPO|Intune/);
|
||||
assert.match(article, /REST API/);
|
||||
});
|
||||
|
||||
test('frames RustDesk compliance around the self-hosted model, not a certification deficit', () => {
|
||||
const article = readPost('remote-desktop-data-sovereignty-gdpr.md');
|
||||
|
||||
assert.match(article, /ISO 27001, SOC 2, or HIPAA/);
|
||||
assert.match(article, /ISO 27001 or HIPAA scope/);
|
||||
assert.match(article, /sales@rustdesk\.com/);
|
||||
assert.doesNotMatch(article, /does not market formal/i);
|
||||
});
|
||||
|
||||
test('every head-to-head comparison post ships FAQ structured data', () => {
|
||||
const comparisons = [
|
||||
'rustdesk-vs-teamviewer.md',
|
||||
'rustdesk-vs-anydesk.md',
|
||||
'rustdesk-vs-screenconnect.md',
|
||||
'rustdesk-vs-splashtop.md',
|
||||
'rustdesk-vs-logmein.md',
|
||||
'rustdesk-vs-rdp.md',
|
||||
'rustdesk-vs-vnc.md',
|
||||
];
|
||||
for (const name of comparisons) {
|
||||
assert.match(readPost(name), /^faq:/m, `${name} should define an faq: block`);
|
||||
test('published article images resolve to local assets', () => {
|
||||
for (const post of posts) {
|
||||
if (!post.image?.startsWith('~/')) continue;
|
||||
const path = join(root, 'src', post.image.slice(2));
|
||||
assert.ok(existsSync(path), post.id + ' references a missing image: ' + post.image);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows concrete self-hosted GDPR controls, not just obligations', () => {
|
||||
const article = readPost('remote-desktop-data-sovereignty-gdpr.md');
|
||||
const englishByKey = new Map(posts.filter((post) => post.lang === 'en').map((post) => [post.translationKey, post]));
|
||||
const codePoints = (text) => [...text].length;
|
||||
|
||||
assert.match(article, /built-in log rotation/i);
|
||||
assert.match(article, /stays on your own server when you self-host/i);
|
||||
assert.match(article, /Control Role/);
|
||||
test('FAQ answers are plain text and translations keep the English FAQ', () => {
|
||||
for (const post of posts) {
|
||||
const faq = post.faq || [];
|
||||
const questions = new Set();
|
||||
const answers = new Set();
|
||||
for (const { question, answer } of faq) {
|
||||
// Answers render as plain text and feed FAQPage JSON-LD, so markdown would show raw.
|
||||
assert.doesNotMatch(answer, /\]\(|\*\*|`/, post.id + ' has markdown in the answer to: ' + question);
|
||||
assert.ok(!questions.has(question), post.id + ' repeats the FAQ question: ' + question);
|
||||
assert.ok(!answers.has(answer), post.id + ' gives the same answer to two FAQ questions');
|
||||
questions.add(question);
|
||||
answers.add(answer);
|
||||
}
|
||||
const english = englishByKey.get(post.translationKey);
|
||||
if (english && english !== post) {
|
||||
assert.equal(faq.length, (english.faq || []).length, post.id + ' must have as many FAQ entries as ' + english.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('explains custom-client build server, retention window, and config non-retention', () => {
|
||||
const article = readPost('rustdesk-web-console-custom-client-generator-port-21114.md');
|
||||
test('posts do not repeat a paragraph', () => {
|
||||
for (const post of posts) {
|
||||
const body = post.source
|
||||
.replace(/^---[\s\S]*?\n---/, '')
|
||||
.replace(/~~~[\s\S]*?~~~|```[\s\S]*?```/g, '');
|
||||
const seen = new Set();
|
||||
for (const block of body.split(/\n\s*\n/)) {
|
||||
const text = block.trim();
|
||||
if (codePoints(text) < 80 || /^[|<#]|^import /.test(text)) continue;
|
||||
assert.ok(!seen.has(text), post.id + ' repeats the paragraph: ' + text.slice(0, 80));
|
||||
seen.add(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.match(article, /compiled on RustDesk's build server/i);
|
||||
assert.match(article, /cross-compile and sign all of those/i);
|
||||
assert.match(article, /automatically deleted from the build server/i);
|
||||
assert.match(article, /deleted automatically once the build finishes/i);
|
||||
test('meta descriptions fit a search snippet', () => {
|
||||
for (const post of posts) {
|
||||
const text = post.metadata?.description || post.excerpt || '';
|
||||
assert.ok(codePoints(text) <= 160, post.id + ' meta description is ' + codePoints(text) + ' characters');
|
||||
}
|
||||
});
|
||||
|
||||
test('license upgrades are described as a one-year extension, not as proration', () => {
|
||||
// The official FAQ: an upgrade extends expiry to a full year and credits the remaining paid value.
|
||||
const proration = /prorat|prorrate|prorata|pro rata|pro-rata|anteilig|按比例|日割り|일할|비례 정산|تناسبي/i;
|
||||
for (const post of posts) {
|
||||
assert.doesNotMatch(post.source, proration, post.id + ' describes license upgrades as prorated');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user