mirror of
https://github.com/BeamMP/BeamMP-Website.git
synced 2026-10-11 19:14:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e1da1cc4b | ||
|
|
b772af5d39 | ||
|
|
e317ede87f | ||
|
|
836b2c3878 | ||
|
|
77c1d01025 | ||
|
|
9f20c38abe | ||
|
|
f45156a143 | ||
|
|
46b0a26969 | ||
|
|
7a268897f1 | ||
|
|
f450856b42 | ||
|
|
5304762dbb |
@@ -0,0 +1,23 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Lint
|
||||
run: npm run lint:check
|
||||
- name: Tests
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -5,7 +5,26 @@ on:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
# Nothing is built and pushed unless the same checks the CI workflow runs pass first.
|
||||
checks:
|
||||
if: "!github.event.release.prerelease"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Lint
|
||||
run: npm run lint:check
|
||||
- name: Tests
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
docker:
|
||||
needs: checks
|
||||
if: "!github.event.release.prerelease"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -5,7 +5,26 @@ on:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
# Nothing is built and pushed unless the same checks the CI workflow runs pass first.
|
||||
checks:
|
||||
if: github.event.release.prerelease
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Lint
|
||||
run: npm run lint:check
|
||||
- name: Tests
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
docker:
|
||||
needs: checks
|
||||
if: github.event.release.prerelease
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -34,6 +34,7 @@ export default [
|
||||
alert: 'readonly',
|
||||
prompt: 'readonly',
|
||||
getComputedStyle: 'readonly',
|
||||
Image: 'readonly',
|
||||
// Node globals
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
@@ -71,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',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
Generated
+1178
-7
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "beammp-website",
|
||||
"private": true,
|
||||
"version": "2.5.4",
|
||||
"version": "2.5.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +76,11 @@
|
||||
"website": "https://tlsservers.com/beammp",
|
||||
"from": "$2.00/mo",
|
||||
"logo": "/partner_logos/TLS_Servers-Studios.png"
|
||||
}
|
||||
{
|
||||
},
|
||||
{
|
||||
"name": "RaveNodes",
|
||||
"website": "https://www.ravenodes.com/beammp-hosting",
|
||||
"from": "$1.00/mo",
|
||||
"logo": "/partner_logos/RaveNodes.png"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { Github, Facebook, Instagram, createLucideIcon } from 'lucide-vue-next'
|
||||
import { Github, Facebook, Instagram, Twitch, createLucideIcon } from 'lucide-vue-next'
|
||||
import { getLocalizedPath } from '@/utils/locale'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
@@ -143,6 +143,16 @@ const TikTokIcon = createLucideIcon('TikTok', [
|
||||
>
|
||||
<BlueskyIcon class="w-5 h-5" />
|
||||
</a>
|
||||
<!-- Twitch -->
|
||||
<a
|
||||
href="https://www.twitch.tv/beammpofficial"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-neutral-500 hover:text-beammp-blue transition-colors dark:text-neutral-400 dark:hover:text-blue-400"
|
||||
aria-label="Twitch"
|
||||
>
|
||||
<Twitch class="w-5 h-5" />
|
||||
</a>
|
||||
<!-- Instagram -->
|
||||
<a
|
||||
href="https://www.instagram.com/beammpofficial"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"partners": "Partners",
|
||||
"servers": "Server",
|
||||
"statistics": "Statistiken",
|
||||
"store": "Merch-Shop",
|
||||
"github": "GitHub",
|
||||
"patreon": "Patreon",
|
||||
"language": "Sprache auswählen",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"partners": "Socios",
|
||||
"servers": "Servidores",
|
||||
"statistics": "Estadísticas",
|
||||
"store": "Tienda de merchandising",
|
||||
"github": "GitHub",
|
||||
"patreon": "Patreon",
|
||||
"language": "Seleccionar idioma",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"partners": "Partenaires",
|
||||
"servers": "Serveurs",
|
||||
"statistics": "Statistiques",
|
||||
"store": "Boutique de produits dérivés",
|
||||
"github": "GitHub",
|
||||
"patreon": "Patreon",
|
||||
"language": "Choisir la langue",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"partners": "Partner",
|
||||
"servers": "Server",
|
||||
"statistics": "Statistiche",
|
||||
"store": "Negozio di merchandising",
|
||||
"github": "GitHub",
|
||||
"patreon": "Patreon",
|
||||
"language": "Seleziona lingua",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"partners": "Партнёры",
|
||||
"servers": "Серверы",
|
||||
"statistics": "Статистика",
|
||||
"store": "Магазин мерча",
|
||||
"github": "GitHub",
|
||||
"patreon": "Patreon",
|
||||
"language": "Выбрать язык",
|
||||
|
||||
+9
-2
@@ -143,13 +143,20 @@ const router = createRouter({
|
||||
|
||||
// Global navigation guard for meta data and locale
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// The language is the first part of the address. The not-found route matches
|
||||
// anything, so it has no `locale` param even for /en/nope: read the language
|
||||
// from the path itself, or /en/nope would look like it has none and be sent
|
||||
// to /en/en/nope, then /en/en/en/nope, and never finish.
|
||||
const firstSegment = to.path.split('/')[1]
|
||||
const pathLocale = SUPPORT_LOCALES.includes(firstSegment) ? firstSegment : null
|
||||
|
||||
// If the path has no locale prefix (e.g. /servers), redirect to /<lang>/servers
|
||||
if (!to.params.locale && to.path !== '/') {
|
||||
if (!to.params.locale && !pathLocale && to.path !== '/') {
|
||||
const locale = localStorage.getItem('lang') || 'en'
|
||||
return next(`/${locale}${to.path}`)
|
||||
}
|
||||
|
||||
const paramsLocale = to.params.locale || 'en'
|
||||
const paramsLocale = to.params.locale || pathLocale || 'en'
|
||||
const i18n = window.i18n
|
||||
|
||||
// Ensure i18n is available
|
||||
|
||||
+4
-6
@@ -16,7 +16,7 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import landingLq from '@/assets/landing-lq.jpg'
|
||||
import landingHq from '@/assets/landing_hq.png'
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const onlinePlayers = ref('...')
|
||||
const onlineServers = ref('...')
|
||||
@@ -37,13 +37,13 @@ const installerDownloadUrl = computed(() => {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch('https://api.beammp.com/metrics')
|
||||
// beammp_players_online 2783
|
||||
// beammp_public_servers 2959
|
||||
// beammp_players_online 2783
|
||||
// beammp_public_servers 2959
|
||||
// beammp_all_servers 4663
|
||||
|
||||
const data = await response.text()
|
||||
const lines = data.split('\n')
|
||||
const values = lines.map(line => line.split(' '))
|
||||
const values = lines.map((line) => line.split(' '))
|
||||
if (values.length >= 3) {
|
||||
onlinePlayers.value = values[0][1]
|
||||
onlineServers.value = values[1][1]
|
||||
@@ -123,7 +123,6 @@ const devFeatures = computed(() => [
|
||||
description: t('message.home.devFeatures.lua.description'),
|
||||
link:
|
||||
'https://docs.beammp.com/' +
|
||||
// eslint-disable-next-line no-undef
|
||||
(locale.value == 'en' ? '' : locale.value + '/') +
|
||||
'scripting/mod-reference/',
|
||||
},
|
||||
@@ -131,7 +130,6 @@ const devFeatures = computed(() => [
|
||||
icon: BookOpen,
|
||||
title: t('message.home.devFeatures.docs.title'),
|
||||
description: t('message.home.devFeatures.docs.description'),
|
||||
// eslint-disable-next-line no-undef
|
||||
link: 'https://docs.beammp.com/' + (locale.value == 'en' ? '' : locale.value + '/'),
|
||||
},
|
||||
{
|
||||
|
||||
+18
-2
@@ -289,7 +289,7 @@ const filteredServers = computed(() => {
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
const matchesSearch =
|
||||
cleanServerName(server.sname).toLowerCase().includes(query) ||
|
||||
plainServerName(server.sname).toLowerCase().includes(query) ||
|
||||
(server.sdesc && server.sdesc.toLowerCase().includes(query)) ||
|
||||
formatMapName(server.map).toLowerCase().includes(query) ||
|
||||
server.owner.toLowerCase().includes(query)
|
||||
@@ -352,7 +352,23 @@ function applyCode(string, codes) {
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex
|
||||
string = string.replace(/\x00*/g, '')
|
||||
return `<span style="${style}">${string}</span>`
|
||||
// The name is set by whoever hosts the server and is shown as HTML (v-html)
|
||||
// so the colours can apply: the text must be escaped, never trusted.
|
||||
return `<span style="${style}">${escapeHtml(string)}</span>`
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// The name as plain text, colour codes removed: what the search looks through.
|
||||
function plainServerName(name) {
|
||||
return name.replace(/\^.{1}/g, '')
|
||||
}
|
||||
|
||||
function cleanServerName(name) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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 = {}
|
||||
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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('&')
|
||||
// ^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 <b> and & shown as typed' }],
|
||||
})
|
||||
wrapper = await openPage('/en/servers')
|
||||
expect(wrapper.find('main').text()).toContain('Tags <b> and & 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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user