Compare commits

...
13 Commits
27 changed files with 2249 additions and 22 deletions
+23
View File
@@ -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
+19
View File
@@ -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:
+19
View File
@@ -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:
+21
View File
@@ -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',
},
},
},
]
+1178 -7
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -1,13 +1,16 @@
{
"name": "beammp-website",
"private": true,
"version": "2.5.3",
"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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 954.005 145.22" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gradient_1" gradientUnits="userSpaceOnUse" x1="-14.28" y1="39.55" x2="39.72" y2="8.22">
<stop offset="0" stop-color="#090D16" />
<stop offset="0.16" stop-color="#090D16" stop-opacity="0.639" />
<stop offset="0.51" stop-color="#090D16" stop-opacity="0.161" />
<stop offset="0.91" stop-color="#090D16" stop-opacity="0" />
</linearGradient>
<linearGradient id="gradient_2" gradientUnits="userSpaceOnUse" x1="52.82" y1="39.55" x2="-1.18" y2="8.22">
<stop offset="0" stop-color="#090D16" />
<stop offset="0.16" stop-color="#090D16" stop-opacity="0.639" />
<stop offset="0.51" stop-color="#090D16" stop-opacity="0.161" />
<stop offset="0.91" stop-color="#090D16" stop-opacity="0" />
</linearGradient>
</defs>
<g id="Group">
<g id="Group" transform="translate(266.134 11.74)">
<path id="Path" d="M93.4901 71.19C93.4901 71.19 76.0601 8.02002 76.0601 8.02002C74.7501 3.29001 70.4301 0.0300293 65.53 0.0700073C65.53 0.0700073 64.72 0.0700073 64.72 0.0700073C59.88 0.110016 55.65 3.36002 54.36 8.03C54.36 8.03 36.96 71.19 36.96 71.19L17.32 0L0 0C0 0 26.54 90.77 26.54 90.77C27.89 95.39 32.13 98.56 36.94 98.56C36.94 98.56 36.94 98.56 36.94 98.56C41.78 98.56 46.03 95.35 47.36 90.71C47.36 90.71 65.17 28.47 65.17 28.47C65.17 28.47 83.1 90.73 83.1 90.73C84.44 95.37 88.68 98.5701 93.5099 98.5701C93.5099 98.5701 93.5099 98.5701 93.5099 98.5701C98.33 98.5701 102.56 95.3901 103.91 90.7701C103.91 90.7701 130.38 9.15527e-05 130.38 9.15527e-05L113.06 9.15527e-05L93.4799 71.1901L93.4901 71.19Z" fill="#FFFFFF" transform="translate(0 0)" />
<rect id="Rectangle" width="16.5" height="73.92" fill="#FFFFFF" fill-rule="evenodd" transform="translate(138.61 24.65)" />
<rect id="Rectangle" width="16.5" height="13.42" fill="#FFFFFF" fill-rule="evenodd" transform="translate(138.61 0)" />
<path id="Path" d="M65.57 18.76C64.57 15.57 63.01 12.56 60.92 9.72C58.82 6.89001 55.99 4.56 52.43 2.73999C48.87 0.919983 44.35 0 38.88 0C31.94 0 26.1 1.51999 21.36 4.54999C18.81 6.17999 16.6 8.12 14.72 10.35C14.72 10.35 14.72 2.11996 14.72 2.11996L0 2.11996L0 76.04L16.7 76.04C16.7 76.04 16.7 38.05 16.7 38.05C16.7 33.53 17.2 29.82 18.21 26.93C19.21 24.03 20.55 21.75 22.21 20.09C23.88 18.42 25.7401 17.2499 27.79 16.5599C29.84 15.8799 31.92 15.5299 34.02 15.5299C37.94 15.5299 41.06 16.3699 43.36 18.0599C45.66 19.7499 47.4 21.9 48.5601 24.5299C49.72 27.1599 50.4601 29.8799 50.78 32.7099C51.1 35.5399 51.26 38.1199 51.26 40.4399C51.26 40.4399 51.26 76.0299 51.26 76.0299L67.96 76.0299C67.96 76.0299 67.96 34.7599 67.96 34.7599C67.96 32.9799 67.81 30.6599 67.51 27.8099C67.21 24.9599 66.56 21.9399 65.56 18.7399C65.56 18.7399 65.57 18.76 65.57 18.76Z" fill="#FFFFFF" transform="translate(170.77 22.52)" />
<path id="Path" d="M65.5699 41.28C64.5699 38.09 63.0099 35.08 60.91 32.24C58.81 29.41 55.98 27.08 52.43 25.26C48.87 23.44 44.35 22.52 38.88 22.52C31.94 22.52 26.1 24.04 21.36 27.07C18.81 28.7 16.61 30.64 14.72 32.87C14.72 32.87 14.72 0 14.72 0L0 0L0 98.57L16.7 98.57C16.7 98.57 16.7 60.58 16.7 60.58C16.7 56.06 17.2 52.35 18.21 49.46C19.21 46.56 20.55 44.28 22.21 42.62C23.87 40.95 25.7401 39.78 27.79 39.09C29.84 38.41 31.92 38.0599 34.02 38.0599C37.94 38.0599 41.06 38.9 43.36 40.59C45.66 42.28 47.4 44.43 48.5601 47.0599C49.72 49.6899 50.4601 52.4099 50.78 55.2399C51.1 58.0699 51.26 60.6499 51.26 62.9699C51.26 62.9699 51.26 98.5599 51.26 98.5599L67.96 98.5599C67.96 98.5599 67.96 57.2899 67.96 57.2899C67.96 55.5099 67.81 53.1899 67.52 50.3399C67.22 47.4899 66.57 44.4699 65.57 41.2699C65.57 41.2699 65.5699 41.28 65.5699 41.28Z" fill="#FFFFFF" transform="translate(253.25 0)" />
<path id="Shape" d="M56.3 5.37C50.94 1.79001 44.42 0 36.76 0C29.5 0 23.11 1.64001 17.59 4.92999C12.07 8.22 7.76001 12.84 4.65002 18.79C1.55005 24.74 0 31.76 0 39.84C0 47.32 1.58002 53.94 4.76001 59.69C7.92999 65.44 12.35 69.94 18 73.18C23.66 76.42 30.23 78.0399 37.71 78.0399C45.1901 78.0399 51.34 76.19 57.2501 72.5C63.1601 68.8 67.5301 63.63 70.36 56.96C70.36 56.96 54.07 52.03 54.07 52.03C52.47 55.45 50.2 58.06 47.26 59.87C44.32 61.67 40.81 62.57 36.75 62.57C30.45 62.57 25.66 60.5201 22.38 56.4101C19.8 53.1901 18.25 48.9101 17.7 43.6101C17.7 43.6101 71.19 43.6101 71.19 43.6101C71.83 34.7601 70.85 27.0701 68.2501 20.5401C65.6501 14.0101 61.6701 8.96008 56.3001 5.38007C56.3001 5.38007 56.3 5.37 56.3 5.37ZM37.44 14.44C43.42 14.44 47.8 16.29 50.58 19.98C52.55 22.6 53.83 26.34 54.42 31.14C54.42 31.14 18.09 31.14 18.09 31.14C18.85 26.87 20.27 23.38 22.3799 20.7C25.6699 16.53 30.6799 14.44 37.4399 14.44C37.4399 14.44 37.44 14.44 37.44 14.44Z" fill="#FFFFFF" transform="translate(331.75 22.6)" />
<path id="Shape" d="M53.22 27.69C48.09 24.29 42.03 22.59 35.0499 22.59C28.0698 22.59 22.1199 24.29 17.35 27.69C17.1 27.87 16.87 28.06 16.63 28.24C16.63 28.24 16.63 0 16.63 0L0 0L0 98.57L14.58 98.57C14.58 98.57 14.58 93.12 14.58 93.12C15.51 93.96 16.48 94.76 17.5199 95.49C22.3999 98.91 28.45 100.62 35.6599 100.62C42.46 100.62 48.3899 98.91 53.46 95.49C58.5299 92.07 62.45 87.41 65.23 81.53C68.01 75.64 69.4 68.9999 69.4 61.61C69.4 54.22 67.99 47.45 65.16 41.59C62.3301 35.73 58.35 31.1 53.2201 27.7C53.2201 27.7 53.22 27.69 53.22 27.69ZM49.9299 73.89C48.58 77.56 46.5199 80.47 43.73 82.62C40.95 84.77 37.41 85.84 33.12 85.84C28.83 85.84 25.12 84.83 22.41 82.79C19.6901 80.7599 17.7101 77.92 16.4501 74.27C15.1901 70.6199 14.5701 66.4 14.5701 61.61C14.5701 56.82 15.2001 52.54 16.4501 48.91C17.7001 45.28 19.66 42.45 22.3 40.42C24.9501 38.39 28.37 37.37 32.5701 37.37C36.9901 37.37 40.65 38.45 43.52 40.62C46.4 42.79 48.52 45.71 49.89 49.38C51.26 53.06 51.9401 57.13 51.9401 61.6C51.9401 66.0699 51.27 70.21 49.92 73.89C49.92 73.89 49.9299 73.89 49.9299 73.89Z" fill="#FFFFFF" transform="translate(414.98 0)" />
<path id="Shape" d="M56.3 5.37C50.9401 1.79001 44.42 0 36.76 0C29.5 0 23.11 1.64001 17.59 4.92999C12.0699 8.22 7.76001 12.84 4.65002 18.79C1.55005 24.74 0 31.76 0 39.84C0 47.32 1.57996 53.94 4.76001 59.69C7.93005 65.44 12.35 69.94 18 73.18C23.66 76.42 30.23 78.0399 37.71 78.0399C45.1899 78.0399 51.34 76.19 57.25 72.5C63.16 68.8 67.53 63.63 70.36 56.96C70.36 56.96 54.0699 52.03 54.0699 52.03C52.47 55.45 50.2 58.06 47.2599 59.87C44.3199 61.67 40.8099 62.57 36.7499 62.57C30.4498 62.57 25.6599 60.5201 22.3799 56.4101C19.7999 53.1901 18.2499 48.9101 17.6998 43.6101C17.6998 43.6101 71.1898 43.6101 71.1898 43.6101C71.8298 34.7601 70.8499 27.0701 68.2499 20.5401C65.6499 14.0101 61.6699 8.96008 56.2999 5.38007C56.2999 5.38007 56.3 5.37 56.3 5.37ZM37.4401 14.44C43.42 14.44 47.8 16.29 50.5801 19.98C52.55 22.6 53.8301 26.34 54.42 31.14C54.42 31.14 18.0901 31.14 18.0901 31.14C18.8501 26.87 20.2701 23.38 22.3801 20.7C25.6702 16.53 30.6802 14.44 37.4402 14.44C37.4402 14.44 37.4401 14.44 37.4401 14.44Z" fill="#FFFFFF" transform="translate(492.19 22.6)" />
<path id="Path" d="M34.8401 0.0815887C32.5101 0.241592 30.2401 0.671585 28.03 1.38158C25.8201 2.09157 23.8 3.06157 21.97 4.29158C19.83 5.61159 17.99 7.29158 16.46 9.32158C15.77 10.2416 15.14 11.2116 14.58 12.2216C14.58 12.2216 14.58 0.381577 14.58 0.381577L0 0.381577L0 74.3016L16.5601 74.3016C16.5601 74.3016 16.5601 36.7216 16.5601 36.7216C16.5601 33.8916 16.9 31.2516 17.5901 28.8116C18.2701 26.3716 19.3401 24.1916 20.7701 22.2716C22.2101 20.3516 24.0602 18.7816 26.3501 17.5517C28.6301 16.1817 31.2001 15.3817 34.05 15.1616C36.9 14.9317 39.42 15.1416 41.6101 15.7816C41.6101 15.7816 41.6101 0.381638 41.6101 0.381638C39.4202 0.0216522 37.1602 -0.0883636 34.8301 0.07164C34.8301 0.07164 34.8401 0.0815887 34.8401 0.0815887Z" fill="#FFFFFF" transform="translate(573.98 24.258)" />
<path id="Shape" d="M54.8298 2.06003C54.8298 2.06003 54.8298 7.36002 54.8298 7.36002C53.9498 6.56003 53.0298 5.80002 52.0598 5.10001C47.2898 1.70001 41.3898 0 34.3699 0C27.35 0 21.3198 1.70001 16.1599 5.10001C10.9999 8.5 7.0199 13.13 4.20996 18.99C1.3999 24.85 0 31.53 0 39.01C0 46.4901 1.39001 53.04 4.17004 58.93C6.95007 64.82 10.88 69.47 15.9401 72.89C21.01 76.31 26.9401 78.02 33.7401 78.02C40.9501 78.02 46.9901 76.31 51.8801 72.89C52.1902 72.68 52.4701 72.44 52.7701 72.22C52.7701 72.22 52.7701 79.26 52.7701 79.26C52.8601 82.77 52.1702 85.71 50.6802 88.06C49.2002 90.41 47.1102 92.19 44.4202 93.4C41.7302 94.61 38.6702 95.21 35.2501 95.21C32.3301 95.21 29.5801 94.56 27.0001 93.26C24.4202 91.96 22.4302 89.96 21.0101 87.27C21.0101 87.27 5.81018 94.8 5.81018 94.8C7.73022 98.18 10.1902 101.06 13.2002 103.46C16.2102 105.86 19.6002 107.69 23.3702 108.97C27.1302 110.25 31.0702 110.89 35.1803 110.89C40.7502 110.89 45.8203 110.14 50.4103 108.63C55.0002 107.12 58.8403 104.83 61.9403 101.75C65.0403 98.67 67.1403 94.73 68.2404 89.94C68.6003 88.3 68.8704 86.62 69.0304 84.91C69.1904 83.2 69.2704 81.41 69.2704 79.54C69.2704 79.54 69.2704 2.05997 69.2704 2.05997L54.8304 2.05997L54.8298 2.06003ZM46.9899 60.21C44.2799 62.2401 40.6998 63.26 36.2799 63.26C31.86 63.26 28.45 62.19 25.6699 60.04C22.8899 57.9 20.8199 54.9901 19.47 51.3101C18.12 47.6401 17.45 43.54 17.45 39.0201C17.45 34.5001 18.13 30.4801 19.5 26.8001C20.87 23.1301 22.99 20.2101 25.87 18.0401C28.75 15.8701 32.4 14.7901 36.8199 14.7901C41.0199 14.7901 44.4399 15.8101 47.09 17.8401C49.74 19.8701 51.6899 22.7001 52.9399 26.3301C54.1899 29.9601 54.8199 34.1901 54.8199 39.0301C54.8199 43.8701 54.1899 48.0401 52.9399 51.6901C51.6799 55.3401 49.7 58.1801 46.98 60.2101C46.98 60.2101 46.9899 60.21 46.9899 60.21Z" fill="#FFFFFF" transform="translate(618.6 22.59)" />
</g>
<g id="Group">
<path id="Path" d="M227.254 35.94C227.254 35.94 204.714 94.6 204.714 94.6C199.264 108.77 185.924 117.95 170.734 117.95C155.544 117.95 142.194 108.77 136.754 94.6C136.754 94.6 114.454 36.57 114.454 36.57C114.454 36.57 92.1744 94.6 92.1744 94.6C86.7144 108.77 73.3844 117.95 58.1744 117.95C50.5844 117.95 43.4344 115.66 37.5244 111.58C31.6144 107.52 26.9244 101.69 24.1944 94.6C24.1944 94.6 1.65439 35.94 1.65439 35.94C-2.10562 26.13 0.664395 15.43 8.73437 8.70996C16.8144 1.96997 27.8144 1.14996 36.7844 6.62997C36.7844 6.62997 48.0344 13.48 48.0344 13.48L75.3943 30.12L65.9743 54.65L45.6443 42.27L27.5743 31.27C27.5743 31.27 48.3243 85.32 48.3243 85.32C50.7444 91.59 56.4843 92.09 58.1843 92.09C59.8843 92.09 65.6343 91.59 68.0443 85.32C68.0443 85.32 95.9143 12.75 95.9143 12.75C98.8843 5.01001 106.174 0 114.464 0C122.754 0 130.044 5.01001 133.014 12.75C133.014 12.75 160.884 85.32 160.884 85.32C163.304 91.59 169.044 92.09 170.744 92.09C172.444 92.09 178.174 91.59 180.604 85.32C180.604 85.32 201.354 31.27 201.354 31.27L183.284 42.27L162.954 54.65L153.534 30.12L180.894 13.46C180.894 13.46 192.144 6.62003 192.144 6.62003C201.114 1.16003 212.134 1.97003 220.194 8.70001C228.254 15.42 231.034 26.12 227.274 35.93C227.274 35.93 227.254 35.94 227.254 35.94Z" fill="#3B6EFF" />
<path id="Polygon" d="M0 16.66L9.41998 41.19L38.54 23.46L27.36 0L0 16.66L0 16.66Z" fill="url(#gradient_1)" fill-rule="evenodd" transform="translate(153.524 13.46)" />
<path id="Polygon" d="M38.54 16.66L29.12 41.19L0 23.46L11.18 0L38.54 16.66L38.54 16.66Z" fill="url(#gradient_2)" fill-rule="evenodd" transform="translate(36.844 13.46)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+8 -2
View File
@@ -51,13 +51,13 @@
"name": "Four Seasons Hosting",
"website": "https://fourseasonshosting.com",
"from": "$3.00/mo",
"logo": "https://fourseasonshosting.com/_next/image?url=%2Flogo.png&w=256&q=75"
"logo": "/partner_logos/four_seasons.png"
},
{
"name": "Winheberg",
"website": "https://winheberg.fr/offres/gaming/beammp",
"from": "$2.99/mo",
"logo": "https://winheberg.fr/img/logo-white.png"
"logo": "/partner_logos/winheberg.svg"
},
{
"name": "Wabbanode",
@@ -76,5 +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"
}
]
+11 -1
View File
@@ -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"
+1
View File
@@ -13,6 +13,7 @@
"partners": "Partners",
"servers": "Server",
"statistics": "Statistiken",
"store": "Merch-Shop",
"github": "GitHub",
"patreon": "Patreon",
"language": "Sprache auswählen",
+1
View File
@@ -13,6 +13,7 @@
"partners": "Socios",
"servers": "Servidores",
"statistics": "Estadísticas",
"store": "Tienda de merchandising",
"github": "GitHub",
"patreon": "Patreon",
"language": "Seleccionar idioma",
+1
View File
@@ -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",
+1
View File
@@ -13,6 +13,7 @@
"partners": "Partner",
"servers": "Server",
"statistics": "Statistiche",
"store": "Negozio di merchandising",
"github": "GitHub",
"patreon": "Patreon",
"language": "Seleziona lingua",
+1
View File
@@ -13,6 +13,7 @@
"partners": "Партнёры",
"servers": "Серверы",
"statistics": "Статистика",
"store": "Магазин мерча",
"github": "GitHub",
"patreon": "Patreon",
"language": "Выбрать язык",
+9 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// The name as plain text, colour codes removed: what the search looks through.
function plainServerName(name) {
return name.replace(/\^.{1}/g, '')
}
function cleanServerName(name) {
+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')
})
})
+133
View File
@@ -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([])
})
})
+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,
},
})