mirror of
https://github.com/BeamMP/BeamMP-Website.git
synced 2026-10-11 19:14:27 +00:00
Add Vitest tests for the languages, router, footer and pages, and a lint:check script
This commit is contained in:
@@ -72,4 +72,24 @@ export default [
|
|||||||
'no-var': 'error',
|
'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
+1176
-5
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -8,6 +8,9 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
|
"lint:check": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"format": "prettier --write \"src/**/*.{js,vue,css,json}\""
|
"format": "prettier --write \"src/**/*.{js,vue,css,json}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -29,12 +32,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
"@vue/test-utils": "^2.5.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
"eslint-plugin-vue": "^10.6.2",
|
"eslint-plugin-vue": "^10.6.2",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"prettier": "^3.7.3",
|
"prettier": "^3.7.3",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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