import assert from 'node:assert/strict'; import { chromium, expect } from '@playwright/test'; import { mkdtemp, rm, readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { createApp } from '../../apps/server/src/app.ts'; import { loadConfig } from '../../apps/server/src/config.ts'; const folder = await mkdtemp(resolve(tmpdir(), 'hop-languages-')), mails = []; const runtime = await createApp( { ...loadConfig({ APP_URL: 'http://127.0.0.1', DATABASE_PATH: resolve(folder, 'db.sqlite'), UPLOAD_DIR: resolve(folder, 'uploads'), MIN_FREE_DISK_MB: '0', LOCATION_DISCOVERY_ENABLED: 'false', CLIENT_IP_SOURCE: 'cloudflare', TRUSTED_PROXY_CIDRS: '127.0.0.1,::1', REGISTRATION_MODE: 'open', SESSION_SECRET: 's'.repeat(40), DISCOVERY_SECRET: 'd'.repeat(40), }), webRoot: resolve('apps/web/dist'), }, { mailer: async (mail) => mails.push(mail) }, ); let browser; try { await runtime.app.listen({ host: '127.0.0.1', port: 0 }); const url = 'http://127.0.0.1:' + runtime.app.server.address().port; runtime.setDevOrigin(url); runtime.service.cfg.appUrl = url; browser = await chromium.launch({ headless: true, ...(process.env.HOP_BROWSER_EXECUTABLE ? { executablePath: process.env.HOP_BROWSER_EXECUTABLE } : {}), }); const errors = []; async function visitor(country, locale = 'en-US', preference, width = 390) { const context = await browser.newContext({ locale, viewport: { width, height: 900 }, isMobile: width < 600, hasTouch: width < 600, extraHTTPHeaders: { 'CF-Connecting-IP': '8.8.8.8', ...(country ? { 'CF-IPCountry': country } : {}), }, }); await context.addInitScript( ({ preference }) => { if (preference) localStorage.setItem('hop-language', preference); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { readText: async () => '', writeText: async (text) => { window.__copied = text; }, }, }); }, { preference }, ); const page = await context.newPage(); page.on('pageerror', (e) => errors.push(e.message)); await page.goto(url); await page.locator('.app-shell[data-online="true"]').waitFor(); await page.locator('.initial-device-loading').waitFor({ state: 'hidden' }); return page; } async function language(page, value) { await page.locator('.language-picker select').selectOption(value); await expect(page.locator('html')).toHaveAttribute('lang', value); } async function noKoreanUI(page) { const text = await page.evaluate(() => { const clone = document.body.cloneNode(true); clone.querySelectorAll('.language-picker,script,style').forEach((el) => el.remove()); return clone.textContent; }); assert.equal(/[가-힣]/.test(text), false, text); assert.equal( await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, ); assert.equal( await page.locator('.login-button').evaluate((button) => { const node = [...button.childNodes].find( (node) => node.nodeType === 3 && node.textContent.trim(), ); const range = document.createRange(); range.selectNodeContents(node); return range.getClientRects().length; }), 1, 'header login text stays on one line', ); } for (const [country, browserLocale, chosen] of [ ['KR', 'en-US', 'ko'], ['US', 'ja-JP', 'en'], [null, 'ja-JP', 'ja'], [null, 'fr-FR', 'en'], ['JP', 'en-US', 'ja'], ]) { const page = await visitor(country, browserLocale); await expect(page.locator('html')).toHaveAttribute('lang', chosen); if (chosen !== 'ko') await noKoreanUI(page); await page.context().close(); } const explicit = await visitor('JP', 'ja-JP', 'en'); await expect(explicit.locator('html')).toHaveAttribute('lang', 'en'); await explicit.context().close(); // A manual choice while the initial country response is in flight wins. const racing = await browser.newContext({ locale: 'en-US', extraHTTPHeaders: { 'CF-Connecting-IP': '8.8.8.8', 'CF-IPCountry': 'KR' }, }); const race = await racing.newPage(); let release; const gate = new Promise((resolve) => (release = resolve)); await race.route('**/api/guest/session', async (route) => { await gate; await route.continue(); }); await race.goto(url); await language(race, 'ja'); release(); await race.locator('.app-shell[data-online="true"]').waitFor(); await expect(race.locator('html')).toHaveAttribute('lang', 'ja'); await racing.close(); const sender = await visitor('US', 'ja-JP', undefined, 1280), receiver = await visitor('JP', 'en-US'); await expect(sender.locator('.login-button')).toHaveText('Log in'); await expect(receiver.locator('.login-button')).toHaveText('ログイン'); const deviceBefore = await receiver.evaluate( async () => (await (await fetch('/api/device/me')).json()).device.id, ); await language(receiver, 'en'); await receiver.reload(); await expect(receiver.locator('html')).toHaveAttribute('lang', 'en'); await receiver.locator('.device-name').waitFor(); assert.equal( await receiver.evaluate(async () => (await (await fetch('/api/device/me')).json()).device.id), deviceBefore, ); await language(receiver, 'ja'); await expect(receiver.locator('link[rel="manifest"]')).toHaveAttribute( 'href', '/manifest.ja.webmanifest', ); assert.equal( (await receiver.evaluate(async () => await (await fetch('/manifest.ja.webmanifest')).json())) .lang, 'ja', ); // Registration, mail and verification all use English; administrator content remains Korean. await sender.locator('.login-button').click(); await sender.getByRole('button', { name: 'Sign up with email', exact: true }).click(); await sender.setViewportSize({ width: 320, height: 900 }); await noKoreanUI(sender); await sender.screenshot({ path: 'tests/e2e/i18n-register-en-mobile.png', fullPage: true }); await sender.setViewportSize({ width: 1280, height: 900 }); await sender.getByLabel('Email', { exact: true }).fill('hello@noname.company'); await sender.getByLabel('Display name', { exact: true }).fill('Japan 日本語 한글'); await sender.getByLabel('Password', { exact: true }).fill('12345678'); await sender.getByLabel('Confirm password', { exact: true }).fill('12345678'); await sender .getByRole('button', { name: 'Sign up and get verification email', exact: true }) .click(); await expect(sender.locator('.auth-message')).toContainText('verification email'); assert.equal(mails.at(-1).subject, 'hop. Verify your email'); const token = mails.at(-1).text.match(/#verify=([\w-]+)/)[1]; await sender.goto(url + '/#verify=' + token); await sender.getByRole('button', { name: 'Verify email', exact: true }).click(); await expect(sender.getByRole('tab', { name: 'Received', exact: true })).toHaveAttribute( 'aria-selected', 'true', ); await sender.getByRole('button', { name: 'Account settings', exact: true }).click(); const settings = sender.getByRole('dialog', { name: 'Account settings', exact: true }); await settings.getByRole('button', { name: 'Change password', exact: true }).click(); await expect(settings.getByLabel('New password', { exact: true })).toBeVisible(); await settings.getByRole('button', { name: 'Close account settings', exact: true }).click(); await sender.getByRole('button', { name: 'hop. Home', exact: true }).click(); // Create in English and join in Japanese. await sender.getByRole('button', { name: 'Connect another device', exact: true }).click(); await sender.getByRole('button', { name: 'Create a room', exact: true }).click(); await sender.getByRole('button', { name: 'Create', exact: true }).click(); const code = (await sender.locator('.invite-code').innerText()).replace(/\s/g, ''); await sender.getByRole('button', { name: 'Close dialog', exact: true }).click(); await receiver.getByRole('button', { name: 'ほかのデバイスと接続', exact: true }).click(); await receiver.getByRole('button', { name: 'ルームに参加', exact: true }).click(); await receiver.getByRole('textbox', { name: '招待コード', exact: true }).fill(code); await receiver.getByRole('button', { name: '参加する', exact: true }).click(); await receiver.locator('.room-leave').waitFor(); await receiver.locator('.modal-backdrop').waitFor({ state: 'hidden' }); await sender.locator('.peer-card').first().click(); const original = '로그인\n日本語 😀 '; await sender.getByLabel('Text to send', { exact: true }).fill(original); // Another tab changes language without remounting or replacing the draft. const other = await sender.context().newPage(); await other.goto(url); await other.locator('.device-name').waitFor(); await language(other, 'ja'); await expect(sender.getByLabel('送信するテキスト', { exact: true })).toHaveValue(original); await language(other, 'en'); await other.close(); await expect(sender.getByLabel('Text to send', { exact: true })).toHaveValue(original); await sender.getByRole('button', { name: 'Send', exact: true }).click(); const request = receiver.getByRole('dialog', { name: '受信の承認リクエスト', exact: true }); await expect(request.locator('.consent-kind')).toHaveText('テキストの受信'); await receiver.screenshot({ path: 'tests/e2e/i18n-request-ja.png', fullPage: true }); await request.getByRole('button', { name: '承認', exact: true }).click(); const received = receiver.getByRole('dialog', { name: '受信したテキスト', exact: true }); await expect(received.getByLabel('受信したテキストの内容', { exact: true })).toHaveText(original); await received.getByRole('button', { name: 'クリップボードにコピー', exact: true }).click(); assert.equal(await receiver.evaluate(() => window.__copied), original); await receiver.screenshot({ path: 'tests/e2e/i18n-received-ja.png', fullPage: true }); await received.getByRole('button', { name: '受信したテキストを閉じる', exact: true }).click(); await sender.locator('.send-dialog').waitFor({ state: 'hidden' }); await sender.locator('.peer-card').first().click(); await sender.getByLabel('Text to send', { exact: true }).fill('https://example.com/rejected'); await sender.getByRole('button', { name: 'Send', exact: true }).click(); await expect(request.locator('.consent-kind')).toHaveText('リンクの受信'); await request.getByRole('button', { name: '辞退', exact: true }).click(); await sender.locator('.send-dialog').waitFor({ state: 'hidden' }); await expect(sender.locator('.toast')).toContainText('declined the transfer'); await sender.locator('.peer-card').first().click(); const filename = '파일 日本語.txt', bytes = Buffer.from('원문 그대로 日本語 😀'); await sender .getByLabel('Choose files to send', { exact: true }) .setInputFiles({ name: filename, mimeType: 'text/plain', buffer: bytes }); await sender.getByRole('button', { name: 'Send', exact: true }).click(); await expect(request.locator('.consent-kind')).toHaveText('ファイルの受信'); const downloaded = receiver.waitForEvent('download'); await request.getByRole('button', { name: '承認', exact: true }).click(); const file = await downloaded; assert.equal(file.suggestedFilename(), filename); assert.deepEqual(await readFile(await file.path()), bytes); await sender.locator('.send-dialog').waitFor({ state: 'hidden' }); await sender.locator('.login-button').click(); await sender.getByRole('button', { name: 'Save to library', exact: true }).count(); await sender.getByRole('tab', { name: 'Saved', exact: true }).click(); await sender.getByRole('button', { name: 'Save to library', exact: true }).click(); await sender.getByRole('button', { name: 'Save clipboard content', exact: true }).click(); await sender.getByLabel('Text to send', { exact: true }).fill('Keep this content unchanged'); await sender.getByRole('button', { name: 'Save', exact: true }).click(); await sender.locator('.item-card').filter({ hasText: 'Keep this content unchanged' }).waitFor(); await expect(sender.locator('.item-footer')).toContainText('[Expires]'); await language(sender, 'ja'); await expect(sender.locator('.item-footer')).toContainText('[保管期限]'); assert.equal(await sender.locator('.item-body').innerText(), 'Keep this content unchanged'); await sender.screenshot({ path: 'tests/e2e/i18n-library-ja.png', fullPage: true }); await language(sender, 'en'); await sender.screenshot({ path: 'tests/e2e/i18n-library-en.png', fullPage: true }); await sender.getByRole('button', { name: 'More', exact: true }).click(); await sender.getByRole('menuitem', { name: 'Administrator', exact: true }).click(); await expect(sender.locator('.admin-panel')).toHaveAttribute('lang', 'ko'); await expect(sender.locator('.admin-panel h1')).toHaveText('서비스 관리'); await expect(sender.locator('html')).toHaveAttribute('lang', 'en'); // The Japanese login form and server-origin error are also localized. await receiver.locator('.login-button').click(); await receiver.setViewportSize({ width: 320, height: 900 }); await noKoreanUI(receiver); await receiver.getByLabel('メールアドレス', { exact: true }).fill('missing@hop.test'); await receiver.getByLabel('パスワード', { exact: true }).fill('12345678'); await receiver .locator('.account-auth') .getByRole('button', { name: 'ログイン', exact: true }) .click(); await expect(receiver.locator('.error-toast')).toContainText( 'メールアドレスとパスワードを確認してください。', ); await receiver.screenshot({ path: 'tests/e2e/i18n-login-ja.png', fullPage: true }); await receiver.evaluate(() => navigator.serviceWorker.ready); await receiver.context().setOffline(true); await receiver.reload({ waitUntil: 'domcontentloaded' }); await expect(receiver.getByText('インターネット接続が必要です。', { exact: true })).toBeVisible(); await expect(receiver.locator('html')).toHaveAttribute('lang', 'ja'); await receiver.context().setOffline(false); assert.deepEqual(errors, []); console.log( 'PASS: trusted country/browser defaults and explicit preference priority/race/reload, cross-tab language changes preserve device/draft, EN registration/mail/verification/account settings, EN→JA rooms/text/copy/link decline/file download, live localized library/dates/errors/manifest, Korean-only administrator and mobile layouts.', ); } finally { await browser?.close(); await runtime.app.close(); await rm(folder, { recursive: true, force: true }); }