Files
hop.noname.company/tests/e2e/localization.mjs

303 lines
15 KiB
JavaScript

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日本語 😀 <script>alert(1)</script> ';
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 });
}