303 lines
15 KiB
JavaScript
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 });
|
|
}
|