Add Vitest tests for the languages, router, footer and pages, and a lint:check script

This commit is contained in:
Starystars67
2026-10-08 21:42:28 +01:00
parent 9f20c38abe
commit 77c1d01025
11 changed files with 2095 additions and 6 deletions
+20
View File
@@ -72,4 +72,24 @@ export default [
'no-var': 'error',
},
},
// Tests: Vitest's globals (vitest.config.js sets `globals: true`) and the
// browser classes jsdom provides.
{
files: ['tests/**/*.js', 'vitest.config.js'],
languageOptions: {
globals: {
describe: 'readonly',
test: 'readonly',
it: 'readonly',
expect: 'readonly',
vi: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
globalThis: 'readonly',
HTMLCanvasElement: 'readonly',
},
},
},
]
+1176 -5
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -8,6 +8,9 @@
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write \"src/**/*.{js,vue,css,json}\""
},
"dependencies": {
@@ -29,12 +32,15 @@
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/test-utils": "^2.5.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^10.6.2",
"jsdom": "^29.1.1",
"prettier": "^3.7.3",
"tw-animate-css": "^1.4.0",
"vite": "^8.0.16"
"vite": "^8.0.16",
"vitest": "^4.1.11"
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* The footer is on every page: the social links, the Patreon link, and the
* legal links. A wrong address or a missing "opens in a new tab" safety
* attribute here is shipped to every visitor.
*/
import { mount, flushPromises } from '@vue/test-utils'
import { installI18n } from './helpers'
import router from '@/routes'
import AppFooter from '@/components/AppFooter.vue'
async function mountFooter() {
const i18n = installI18n('en')
await router.push('/en')
await router.isReady()
const wrapper = mount(AppFooter, { global: { plugins: [i18n, router] } })
await flushPromises()
return wrapper
}
const SOCIAL = {
GitHub: 'https://github.com/BeamMP',
Discord: 'https://discord.gg/beammp',
YouTube: 'https://www.youtube.com/@beammpofficial',
X: 'https://x.com/beammpofficial',
Reddit: 'https://www.reddit.com/r/BeamMP',
Bluesky: 'https://bsky.app/profile/beammp.com',
Twitch: 'https://www.twitch.tv/beammpofficial',
Instagram: 'https://www.instagram.com/beammpofficial',
TikTok: 'https://www.tiktok.com/@beammpofficial',
Facebook: 'https://www.facebook.com/BeamMPTeam',
}
test('shows each social link, in order, with the right address', async () => {
const wrapper = await mountFooter()
const links = wrapper.findAll('a[aria-label]')
expect(links.map((a) => a.attributes('aria-label'))).toEqual(Object.keys(SOCIAL))
for (const a of links) {
expect(a.attributes('href'), a.attributes('aria-label')).toBe(
SOCIAL[a.attributes('aria-label')]
)
}
})
test('Twitch and Bluesky are both there, side by side', async () => {
const wrapper = await mountFooter()
const labels = wrapper.findAll('a[aria-label]').map((a) => a.attributes('aria-label'))
expect(labels.indexOf('Twitch')).toBe(labels.indexOf('Bluesky') + 1)
})
test('every link that leaves the site opens in a new tab without handing over the opener', async () => {
const wrapper = await mountFooter()
const external = wrapper
.findAll('a')
.filter((a) => /^https?:\/\//.test(a.attributes('href') || ''))
expect(external.length).toBeGreaterThanOrEqual(Object.keys(SOCIAL).length + 3)
for (const a of external) {
expect(a.attributes('target'), a.attributes('href')).toBe('_blank')
expect(a.attributes('rel'), a.attributes('href')).toContain('noopener')
}
})
test('every social icon is a labelled link with a drawn icon', async () => {
const wrapper = await mountFooter()
for (const a of wrapper.findAll('a[aria-label]')) {
expect(a.find('svg').exists(), a.attributes('aria-label')).toBe(true)
}
})
test('has the Patreon link and the legal links, and this year in the copyright', async () => {
const wrapper = await mountFooter()
const hrefs = wrapper.findAll('a').map((a) => a.attributes('href'))
expect(hrefs).toContain('https://www.patreon.com/BeamMP')
expect(hrefs).toContain('https://beammp.com/privacy')
expect(hrefs).toContain('https://beammp.com/terms')
expect(hrefs).toContain('/en/about')
expect(wrapper.text()).toContain(`2019 - ${new Date().getFullYear()}`)
})
test('the About link follows the language', async () => {
const i18n = installI18n('en')
await router.push('/de')
const wrapper = mount(AppFooter, { global: { plugins: [i18n, router] } })
await flushPromises()
expect(wrapper.findAll('a').map((a) => a.attributes('href'))).toContain('/de/about')
})
+90
View File
@@ -0,0 +1,90 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { flushPromises, mount } from '@vue/test-utils'
import { setupI18n } from '@/i18n'
import en from '@/locales/en.json'
import router from '@/routes'
import App from '@/App.vue'
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
export const publicFile = (name) => fs.readFileSync(path.join(root, 'public', name), 'utf8')
// The same i18n the app builds in main.js, with English loaded. The router's
// guard reads window.i18n to switch language, so it is set here too.
export function installI18n(locale = 'en') {
const i18n = setupI18n({ legacy: false, locale, fallbackLocale: 'en', messages: { en } })
window.i18n = i18n
return i18n
}
const reply = (body, ok = true, status = ok ? 200 : 503) => ({
ok,
status,
json: async () => (typeof body === 'string' ? JSON.parse(body) : body),
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
})
/**
* Stands in for fetch. Local files come from the repo's public folder; the
* backend feeds can be given as data, or left to answer 503 ("backend down").
* Nothing reaches the network, and a URL nobody planned for fails the test.
*/
export function stubFetch({ servers, metrics } = {}) {
const calls = []
const stub = async (url) => {
const address = String(url)
calls.push(address)
if (
address.startsWith('/docs/') ||
address === '/partners.json' ||
address === '/servers.json'
) {
return reply(publicFile(address.slice(1)))
}
if (address === 'https://backend.beammp.com/servers-info')
return servers ? reply(servers) : reply('', false)
if (address === 'https://api.beammp.com/metrics')
return metrics ? reply(metrics) : reply('', false)
// The statistics page's release and stats feeds: down unless a test says otherwise.
if (
/^https:\/\/(api|backend)\.beammp\.com\//.test(address) ||
address.startsWith('https://api.github.com/')
)
return reply('', false)
throw new Error(`Unplanned fetch to ${address}`)
}
stub.calls = calls
vi.stubGlobal('fetch', stub)
return stub
}
/**
* Runs `action` (a navigation) and fails it if the router's language guard
* keeps redirecting. A runaway redirect would otherwise freeze the test run
* the way it freezes the page. The guard reads the saved language on every
* redirect, which is what is counted.
*/
export async function withRedirectLimit(action, limit = 5) {
const real = localStorage.getItem.bind(localStorage)
let passes = 0
localStorage.getItem = (key) => {
if (key === 'lang' && ++passes > limit) throw new Error('still redirecting')
return real(key)
}
try {
return await action()
} finally {
localStorage.getItem = real
}
}
/** Opens a page of the real app (router, layout, nav and footer) and lets it finish loading. */
export async function openPage(pathname) {
const i18n = installI18n('en')
await withRedirectLimit(() => router.push(pathname))
await router.isReady()
const wrapper = mount(App, { attachTo: document.body, global: { plugins: [i18n, router] } })
await flushPromises()
return wrapper
}
+67
View File
@@ -0,0 +1,67 @@
/**
* The helpers that build and switch the /<language>/... paths. Every link on
* the site goes through these, so a slip here breaks navigation everywhere.
*/
import { getCurrentLocale, getLocalizedPath, switchLocale } from '@/utils/locale'
afterEach(() => {
delete window.i18n
})
describe('getLocalizedPath', () => {
test('puts the language first, with or without a leading slash on the page', () => {
expect(getLocalizedPath('servers', 'de')).toBe('/de/servers')
expect(getLocalizedPath('/servers', 'de')).toBe('/de/servers')
})
test('never doubles a slash', () => {
expect(getLocalizedPath('//servers', 'fr')).toBe('/fr/servers')
expect(getLocalizedPath('', 'fr')).toBe('/fr/')
})
test('uses the saved language when none is given, and English if nothing is saved', () => {
expect(getLocalizedPath('about')).toBe('/en/about')
localStorage.setItem('lang', 'it')
expect(getLocalizedPath('about')).toBe('/it/about')
})
test('the language the app is showing wins over the saved one', () => {
localStorage.setItem('lang', 'it')
window.i18n = { global: { locale: { value: 'ru' } } }
expect(getLocalizedPath('about')).toBe('/ru/about')
})
})
describe('getCurrentLocale', () => {
test('reads a plain string locale as well as a ref', () => {
window.i18n = { global: { locale: 'es' } }
expect(getCurrentLocale()).toBe('es')
window.i18n = { global: { locale: { value: 'zh' } } }
expect(getCurrentLocale()).toBe('zh')
})
})
describe('switchLocale', () => {
test('swaps the language and keeps the page', () => {
expect(switchLocale('de', '/en/servers')).toBe('/de/servers')
expect(switchLocale('fr', '/ru/about')).toBe('/fr/about')
})
test('keeps deeper paths', () => {
expect(switchLocale('de', '/en/docs/getting-started')).toBe('/de/docs/getting-started')
})
test('the home page of one language is the home page of the next', () => {
expect(switchLocale('es', '/en')).toBe('/es')
expect(switchLocale('es', '/en/')).toBe('/es')
})
test('drops the old query string rather than carrying it across', () => {
expect(switchLocale('de', '/en/servers?search=drift')).toBe('/de/servers')
})
test('adds a language to a path that has none', () => {
expect(switchLocale('de', '/servers')).toBe('/de/servers')
})
})
+135
View File
@@ -0,0 +1,135 @@
/**
* The site is shown in seven languages from seven JSON files. Nothing else
* notices when one of them falls behind, so this does: every language has
* every English text, nothing is blank, translations keep the same {placeholders}
* as English, and every text a page asks for exists.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { LANGUAGES, SUPPORT_LOCALES } from '@/i18n'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const localesDir = path.join(root, 'src/locales')
const read = (code) => JSON.parse(fs.readFileSync(path.join(localesDir, `${code}.json`), 'utf8'))
function flatten(obj, prefix = '') {
const out = {}
for (const [key, value] of Object.entries(obj)) {
const full = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value))
Object.assign(out, flatten(value, full))
else out[full] = value
}
return out
}
// Texts that are known to be untranslated, so the test can pass today and still
// catch anything new. Translate one and delete it from here: the "still
// missing" test below fails if an entry is left behind.
const KNOWN_UNTRANSLATED = {
'message.nav.store': ['de', 'es', 'fr', 'it', 'ru'],
}
const english = flatten(read('en'))
const others = SUPPORT_LOCALES.filter((code) => code !== 'en')
describe('the language list', () => {
test('every supported language has a file, and every file is a supported language', () => {
const files = fs
.readdirSync(localesDir)
.filter((f) => f.endsWith('.json'))
.map((f) => f.replace('.json', ''))
expect(files.sort()).toEqual([...SUPPORT_LOCALES].sort())
})
test('every supported language has a name and a flag for the picker', () => {
for (const code of SUPPORT_LOCALES) {
expect(LANGUAGES[code]).toMatchObject({
code,
name: expect.any(String),
flag: expect.any(String),
})
}
})
})
describe.each(others)('%s translation', (code) => {
const messages = flatten(read(code))
const allowedMissing = (key) => (KNOWN_UNTRANSLATED[key] || []).includes(code)
test('has every English text', () => {
const missing = Object.keys(english).filter((key) => !(key in messages) && !allowedMissing(key))
expect(missing).toEqual([])
})
test('has no text English does not have (a leftover or a typo in the name)', () => {
expect(Object.keys(messages).filter((key) => !(key in english))).toEqual([])
})
test('has no blank texts', () => {
const blank = Object.entries(messages).filter(
([, value]) => typeof value !== 'string' || !value.trim()
)
expect(blank.map(([key]) => key)).toEqual([])
})
test('keeps the same {placeholders} as English', () => {
const placeholders = (text) => [...String(text).matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort()
const mismatched = Object.keys(messages)
.filter((key) => key in english)
.filter(
(key) =>
JSON.stringify(placeholders(messages[key])) !== JSON.stringify(placeholders(english[key]))
)
expect(mismatched).toEqual([])
})
})
test('every entry in the known-untranslated list is still untranslated', () => {
const stale = []
for (const [key, codes] of Object.entries(KNOWN_UNTRANSLATED)) {
if (!(key in english)) stale.push(`${key} (not in English)`)
for (const code of codes)
if (key in flatten(read(code))) stale.push(`${key} (${code} has it now)`)
}
expect(stale).toEqual([])
})
describe('texts the pages ask for', () => {
const sourceFiles = []
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (/\.(vue|js)$/.test(entry.name)) sourceFiles.push(full)
}
}
walk(path.join(root, 'src'))
const used = new Map()
const dynamic = []
for (const file of sourceFiles) {
const source = fs.readFileSync(file, 'utf8')
for (const m of source.matchAll(/(?<![\w.])\$?(?:t|te|tm|rt)\(\s*(['"`])([^'"`]+)\1/g)) {
if (m[2].includes('${')) dynamic.push(`${path.relative(root, file)}: ${m[2]}`)
else used.set(m[2], path.relative(root, file))
}
}
test('finds the texts the pages use (the scan itself works)', () => {
expect(used.size).toBeGreaterThan(50)
})
test('every text a page asks for exists in English', () => {
const missing = [...used].filter(
([key]) => !(key in english) && !Object.keys(english).some((k) => k.startsWith(`${key}.`))
)
expect(missing.map(([key, file]) => `${key} (${file})`)).toEqual([])
})
test('no page builds a text name on the fly, which this check could not see', () => {
expect(dynamic).toEqual([])
})
})
+274
View File
@@ -0,0 +1,274 @@
/**
* Opens each page of the real app (router, navigation, page, footer) with
* fetch stubbed, the way a visitor gets it. A page that throws while loading,
* logs a Vue warning, or goes blank when the backend is down shows up here.
* The checks are about what a page says, not how it looks.
*/
import { openPage, publicFile, stubFetch } from './helpers'
const PAGES = [
['home', '/en'],
['about', '/en/about'],
['communities', '/en/communities'],
['servers', '/en/servers'],
['statistics', '/en/stats'],
['partners', '/en/partners'],
['privacy', '/en/privacy'],
['terms', '/en/terms'],
]
let wrapper
let warnings
beforeEach(() => {
warnings = []
vi.spyOn(console, 'warn').mockImplementation((...args) => warnings.push(args.join(' ')))
vi.spyOn(console, 'error').mockImplementation((...args) => warnings.push(args.join(' ')))
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe.each(PAGES)('the %s page', (_name, address) => {
test('opens with the navigation and footer around it, and no Vue warnings', async () => {
stubFetch()
wrapper = await openPage(address)
expect(wrapper.find('nav, header').exists()).toBe(true)
expect(wrapper.find('footer').exists()).toBe(true)
expect(wrapper.find('main').text().length).toBeGreaterThan(0)
expect(warnings.filter((w) => w.includes('[Vue warn]'))).toEqual([])
})
test('still opens, and says something, when the backend is down', async () => {
stubFetch()
wrapper = await openPage(address)
expect(wrapper.find('main').text().trim()).not.toBe('')
expect(wrapper.find('footer').exists()).toBe(true)
})
})
describe('home', () => {
test('shows the live numbers from the metrics feed', async () => {
stubFetch({
metrics: 'beammp_players_online 2783\nbeammp_public_servers 2959\nbeammp_all_servers 4663\n',
})
wrapper = await openPage('/en')
const text = wrapper.text()
expect(text).toContain('2783')
expect(text).toContain('2959')
expect(text).toContain('4663')
})
test('shows N/A rather than a broken number when the metrics are down', async () => {
vi.stubGlobal('fetch', async () => {
throw new Error('network down')
})
wrapper = await openPage('/en')
expect(wrapper.text()).toContain('N/A')
expect(wrapper.text()).not.toContain('undefined')
})
})
describe('the docs links on the home page', () => {
test('point at the docs in the language of the page', async () => {
stubFetch()
wrapper = await openPage('/en')
const english = wrapper.findAll('a').map((a) => a.attributes('href'))
expect(english).toContain('https://docs.beammp.com/')
expect(english).toContain('https://docs.beammp.com/scripting/mod-reference/')
wrapper.unmount()
wrapper = await openPage('/de')
const german = wrapper.findAll('a').map((a) => a.attributes('href'))
expect(german).toContain('https://docs.beammp.com/de/')
expect(german).toContain('https://docs.beammp.com/de/scripting/mod-reference/')
})
})
describe('servers', () => {
const servers = JSON.parse(publicFile('servers.json')).slice(0, 25)
test('lists the servers the backend returns', async () => {
stubFetch({ servers })
wrapper = await openPage('/en/servers')
const text = wrapper.find('main').text()
expect(text).toContain('MTRTR Christmas')
expect(text).not.toContain('Failed to load servers')
})
// A server's name is set by whoever hosts it and is shown as HTML so the
// colour codes can apply, so the page escapes it itself. (The API also strips
// every `<` at heartbeat, which is why the real feed has none: this is the
// second layer, and it holds even if the first ever slips.)
test('a server name with HTML in it is shown as text, not turned into page elements', async () => {
const hostile = {
...servers[0],
sname: '^1Evil <img src="x-probe" onerror="window.__pwned=1"> <b id="probe-b">bold</b>',
}
stubFetch({ servers: [hostile] })
wrapper = await openPage('/en/servers')
expect(document.querySelector('img[src="x-probe"]')).toBeNull()
expect(document.querySelector('#probe-b')).toBeNull()
expect(wrapper.find('main').text()).toContain('<b id="probe-b">bold</b>')
})
test('names with & and quotes still read as written, and keep their colours', async () => {
const named = [
{ ...servers[0], ident: 'a1', sname: '^1C&B\'s ^2"Banger" League' },
{ ...servers[1], ident: 'a2', sname: 'Plain & simple' },
]
stubFetch({ servers: named })
wrapper = await openPage('/en/servers')
const text = wrapper.find('main').text()
expect(text).toContain('C&B\'s "Banger" League')
expect(text).toContain('Plain & simple')
expect(text).not.toContain('&amp;')
// ^1 is dark blue: the colour codes are not shown, and the colour is applied
expect(text).not.toContain('^1')
expect(wrapper.find('.name-cell span[style*="color"]').exists()).toBe(true)
})
test('text that looks like an HTML entity is shown exactly as typed', async () => {
stubFetch({
servers: [{ ...servers[0], ident: 'a1', sname: 'Tags &lt;b&gt; and &amp; shown as typed' }],
})
wrapper = await openPage('/en/servers')
expect(wrapper.find('main').text()).toContain('Tags &lt;b&gt; and &amp; shown as typed')
})
describe('searching', () => {
const named = () => [
{
...servers[0],
ident: 'a1',
sname: "^1C&B's ^2Banger League",
sdesc: 'drift night',
owner: 'zed',
map: '/levels/west_coast_usa/info.json',
},
{
...servers[1],
ident: 'a2',
sname: '^4Quiet Valley',
sdesc: 'relaxed driving',
owner: 'amy',
map: '/levels/italy/info.json',
},
]
const search = async (query) => {
await wrapper.find('input.search-input').setValue(query)
return wrapper.findAll('.name-cell').map((cell) => cell.text())
}
test('finds a name containing & or an apostrophe by what you type', async () => {
stubFetch({ servers: named() })
wrapper = await openPage('/en/servers')
expect(await search("c&b's")).toEqual([expect.stringContaining("C&B's Banger League")])
expect(await search('banger league')).toHaveLength(1)
})
test('does not match on the colour markup behind the name', async () => {
stubFetch({ servers: named() })
wrapper = await openPage('/en/servers')
expect(await search('span')).toEqual([])
expect(await search('color')).toEqual([])
})
test('still finds by description and owner', async () => {
stubFetch({ servers: named() })
wrapper = await openPage('/en/servers')
expect(await search('relaxed')).toEqual([expect.stringContaining('Quiet Valley')])
expect(await search('zed')).toEqual([expect.stringContaining("C&B's")])
})
})
test('says so when the servers cannot be loaded', async () => {
stubFetch()
wrapper = await openPage('/en/servers')
expect(wrapper.find('main').text()).toContain('Failed to load servers')
})
})
describe('the policy pages', () => {
test.each([
['privacy', '/en/privacy', 'docs/privacy_policy.md'],
['terms', '/en/terms', 'docs/terms.md'],
])('%s shows the text of its document', async (_name, address, file) => {
stubFetch()
wrapper = await openPage(address)
const firstHeading = publicFile(file)
.split('\n')
.find((line) => /^#\s+\S/.test(line))
.replace(/^#\s+/, '')
.trim()
expect(wrapper.find('main').text()).toContain(firstHeading)
})
})
describe('partners', () => {
test('shows each partner in the partners file', async () => {
stubFetch()
wrapper = await openPage('/en/partners')
const partners = JSON.parse(publicFile('partners.json'))
const list = Array.isArray(partners) ? partners : Object.values(partners).flat()
expect(list.length).toBeGreaterThan(0)
const names = list.map((p) => p.name).filter(Boolean)
expect(names.length).toBeGreaterThan(0)
for (const name of names) expect(wrapper.find('main').text(), name).toContain(name)
})
})
describe('getting around', () => {
// The Communities, Stats and forum entries sit in dropdown menus that only
// draw their links once opened, so they are not on a freshly loaded page.
test('the home page links to the main pages in the current language', async () => {
stubFetch()
wrapper = await openPage('/en')
const hrefs = wrapper.findAll('a').map((a) => a.attributes('href'))
for (const page of ['/en/', '/en/communities', '/en/partners', '/en/servers', '/en/about']) {
expect(hrefs, page).toContain(page)
}
})
test('the navigation bar links out to the docs, the store, GitHub and Patreon', async () => {
stubFetch()
wrapper = await openPage('/en')
const hrefs = wrapper.findAll('nav a, header a').map((a) => a.attributes('href'))
for (const url of [
'https://docs.beammp.com',
'https://store.beammp.com',
'https://github.com/BeamMP/BeamMP',
'https://www.patreon.com/BeamMP',
]) {
expect(hrefs, url).toContain(url)
}
})
test('the language is part of every internal link', async () => {
stubFetch()
wrapper = await openPage('/en')
const internal = wrapper
.findAll('a')
.map((a) => a.attributes('href'))
.filter((h) => h.startsWith('/') && !h.startsWith('/installer'))
expect(internal.length).toBeGreaterThan(4)
for (const href of internal) expect(href).toMatch(/^\/en(\/|$)/)
})
})
describe('the not-found page', () => {
test('opens at a missing address, with its message and a way back', async () => {
stubFetch()
wrapper = await openPage('/en/this-page-does-not-exist')
expect(wrapper.find('main').text()).toContain('404')
expect(wrapper.find('main a').exists()).toBe(true)
expect(warnings.filter((w) => w.includes('[Vue warn]'))).toEqual([])
})
})
+127
View File
@@ -0,0 +1,127 @@
/**
* Every address on the site starts with the language: /de/servers. The router
* adds one when it is missing, remembers the one in use, switches the page's
* language and sets the title. The real router and its guard run here.
*/
import { installI18n, withRedirectLimit } from './helpers'
import router from '@/routes'
// Pushing the address the router is already on does nothing (and so skips the
// guard), so every test starts from the same neutral page.
beforeEach(async () => {
installI18n('en')
await router.push('/en/privacy')
localStorage.clear()
installI18n('en')
document.title = ''
})
describe('addresses without a language', () => {
test('the home address goes to the language you used last, or English', async () => {
await router.push('/')
expect(router.currentRoute.value.fullPath).toBe('/en')
expect(router.currentRoute.value.name).toBe('Home')
localStorage.setItem('lang', 'fr')
await router.push('/')
expect(router.currentRoute.value.fullPath).toBe('/fr')
})
test('a page address gets the language put in front of it', async () => {
await router.push('/servers')
expect(router.currentRoute.value.fullPath).toBe('/en/servers')
expect(router.currentRoute.value.name).toBe('Servers')
localStorage.setItem('lang', 'de')
await router.push('/about')
expect(router.currentRoute.value.fullPath).toBe('/de/about')
})
})
describe('opening a page in a language', () => {
test('sets the page language, remembers it, and sets the title', async () => {
await router.push('/de/about')
expect(router.currentRoute.value.name).toBe('About')
expect(document.documentElement.getAttribute('lang')).toBe('de')
expect(localStorage.getItem('lang')).toBe('de')
expect(document.title).toBe('About - BeamMP')
})
test('loads a language the first time it is used', async () => {
expect(window.i18n.global.availableLocales).toEqual(['en'])
await router.push('/ru/about')
expect(window.i18n.global.availableLocales).toContain('ru')
})
test('every language in the picker opens', async () => {
for (const code of ['en', 'es', 'fr', 'de', 'it', 'ru', 'zh']) {
await router.push(`/${code}/partners`)
expect(router.currentRoute.value.fullPath).toBe(`/${code}/partners`)
expect(document.documentElement.getAttribute('lang')).toBe(code)
}
})
})
describe('the pages', () => {
const pages = ['', 'about', 'communities', 'servers', 'stats', 'partners', 'privacy', 'terms']
test.each(pages)('/en/%s opens its page', async (page) => {
await router.push(`/en/${page}`)
expect(router.currentRoute.value.matched.length).toBeGreaterThan(0)
expect(router.currentRoute.value.name).not.toBe('NotFound')
})
test('every page has its own title and description for search engines and tabs', () => {
const named = router.getRoutes().filter((r) => r.name && r.name !== 'NotFound')
expect(named.length).toBeGreaterThanOrEqual(pages.length)
for (const route of named) {
expect(route.meta.title, `${String(route.name)} title`).toMatch(/ - BeamMP$/)
expect(route.meta.description, `${String(route.name)} description`).toBeTruthy()
}
const titles = named.map((r) => r.meta.title)
expect(new Set(titles).size).toBe(titles.length)
})
})
// This used to never finish: the not-found route matches anything, so it has
// no `locale` param even for /en/nope. The guard took that to mean "no
// language", redirected to /en/en/nope, which also has none, and so on - the
// page froze instead of showing the not-found page. withRedirectLimit stops a
// runaway redirect so a regression fails the test instead of hanging it.
describe('an address the site does not have', () => {
const openWithRedirectLimit = (address) => withRedirectLimit(() => router.push(address))
test.each([
['/en/does-not-exist', '/en/does-not-exist'],
['/de/does-not-exist', '/de/does-not-exist'],
['/en/servers/nope/deeper', '/en/servers/nope/deeper'],
])('%s shows the not-found page where it is', async (address, expected) => {
await openWithRedirectLimit(address)
expect(router.currentRoute.value.name).toBe('NotFound')
expect(router.currentRoute.value.fullPath).toBe(expected)
})
test('one with no language gets the language you last used, then the not-found page', async () => {
await openWithRedirectLimit('/does-not-exist')
expect(router.currentRoute.value.fullPath).toBe('/en/does-not-exist')
expect(router.currentRoute.value.name).toBe('NotFound')
localStorage.setItem('lang', 'fr')
await openWithRedirectLimit('/also-missing')
expect(router.currentRoute.value.fullPath).toBe('/fr/also-missing')
expect(router.currentRoute.value.name).toBe('NotFound')
})
test('a language the site does not have is treated as part of the missing address', async () => {
await openWithRedirectLimit('/xx/about')
expect(router.currentRoute.value.fullPath).toBe('/en/xx/about')
expect(router.currentRoute.value.name).toBe('NotFound')
})
test('the not-found page is shown in the language of the address', async () => {
await openWithRedirectLimit('/de/does-not-exist')
expect(document.documentElement.getAttribute('lang')).toBe('de')
expect(localStorage.getItem('lang')).toBe('de')
})
})
+88
View File
@@ -0,0 +1,88 @@
// Runs before every test file (see vitest.config.js).
// jsdom has no matchMedia, which the theme toggle reads.
if (!window.matchMedia) {
window.matchMedia = (query) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})
}
// jsdom has no IntersectionObserver / ResizeObserver, which some views use.
for (const name of ['IntersectionObserver', 'ResizeObserver']) {
if (!window[name]) {
window[name] = class {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return []
}
}
}
}
// jsdom cannot draw: canvas.getContext() is null, and the statistics charts call
// ctx.scale() straight away. A stand-in whose every method does nothing lets
// the page run; what it would have drawn is not what is tested.
HTMLCanvasElement.prototype.getContext = function getContext() {
const noop = () => {}
return new Proxy(
{ canvas: this },
{
get: (target, prop) =>
prop in target ? target[prop] : prop === 'measureText' ? () => ({ width: 0 }) : noop,
set: (target, prop, value) => {
target[prop] = value
return true
},
}
)
}
// Node 22+ has its own `localStorage` global that wins over jsdom's and does
// nothing useful unless node is started with --localstorage-file. A plain
// in-memory one means the tests do not depend on the node version or a flag.
class MemoryStorage {
#map = new Map()
getItem(key) {
return this.#map.has(key) ? this.#map.get(key) : null
}
setItem(key, value) {
this.#map.set(key, String(value))
}
removeItem(key) {
this.#map.delete(key)
}
clear() {
this.#map.clear()
}
key(index) {
return Array.from(this.#map.keys())[index] ?? null
}
get length() {
return this.#map.size
}
}
const memoryStorage = new MemoryStorage()
Object.defineProperty(globalThis, 'localStorage', {
value: memoryStorage,
configurable: true,
writable: true,
})
Object.defineProperty(window, 'localStorage', {
value: memoryStorage,
configurable: true,
writable: true,
})
beforeEach(() => {
localStorage.clear()
})
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
// Tailwind's plugin is left out on purpose: the tests check what a page says
// and links to, not how it looks.
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'jsdom',
environmentOptions: {
jsdom: { url: 'http://localhost:5173' },
},
globals: true,
setupFiles: ['./tests/setup.js'],
// The first test to mount a big view pays for compiling it too.
testTimeout: 20000,
hookTimeout: 20000,
},
})