phase 7+: i18n primitive + login language picker + autocomplete-off

Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.

Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.

Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.

Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
  body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-07 16:14:40 +02:00
parent 22b0710d04
commit 9101aba816
20 changed files with 918 additions and 66 deletions
+7 -1
View File
@@ -2303,7 +2303,13 @@ components:
format: email format: email
locale: locale:
type: string type: string
description: Optional BCP 47 locale tag preferred for the delivered code. description: |
Optional BCP 47 locale tag preferred for the delivered code.
Read by the gateway in preference to the request
`Accept-Language` header so Safari clients (which silently
drop JS-set `Accept-Language`) can still pick a non-system
mail language. Empty / malformed values fall back to the
header, which in turn falls back to `en`.
PublicAuthSendEmailCodeResponse: PublicAuthSendEmailCodeResponse:
type: object type: object
additionalProperties: false additionalProperties: false
+9 -6
View File
@@ -100,12 +100,15 @@ Branches inside backend:
new one. The client gets the same response shape and is unaware of new one. The client gets the same response shape and is unaware of
the reuse. the reuse.
- **Otherwise.** Backend creates a new challenge with the resolved - **Otherwise.** Backend creates a new challenge with the resolved
preferred language (derived from the optional `Accept-Language` preferred language (derived from the optional `locale` body field
header forwarded by gateway, falling back to a default), and the caller sends — which takes priority — or, if absent or blank,
enqueues the auth-mail row directly into the outbox in the same from the `Accept-Language` header forwarded by gateway, falling
transaction. SMTP delivery is asynchronous; the auth response back to a default), and enqueues the auth-mail row directly into
returns as soon as the challenge and outbox rows are durably the outbox in the same transaction. SMTP delivery is asynchronous;
committed. the auth response returns as soon as the challenge and outbox rows
are durably committed. The body field is the canonical channel
because Safari silently drops JS-set `Accept-Language` headers;
non-Safari clients can still rely on the header alone.
### 1.3 Confirming the challenge ### 1.3 Confirming the challenge
+9 -5
View File
@@ -99,11 +99,15 @@ Backend выпускает непрозрачный идентификатор
backend переиспользует последний имеющийся вызов вместо создания backend переиспользует последний имеющийся вызов вместо создания
нового. Клиент получает ту же форму ответа и не знает о повторе. нового. Клиент получает ту же форму ответа и не знает о повторе.
- **Иначе.** Backend создаёт новый вызов с разрешённым preferred_language - **Иначе.** Backend создаёт новый вызов с разрешённым preferred_language
(выводится из опционального заголовка `Accept-Language`, (выводится из опционального поля `locale` в JSON-теле — оно имеет
форварднутого gateway, с откатом на дефолт) и в той же транзакции приоритет — либо, если оно отсутствует или пустое, из заголовка
ставит auth-mail-строку прямо в outbox. SMTP-доставка асинхронна; `Accept-Language`, форварднутого gateway, с откатом на дефолт) и
auth-ответ возвращается, как только строки challenge и outbox в той же транзакции ставит auth-mail-строку прямо в outbox.
durably закоммитены. SMTP-доставка асинхронна; auth-ответ возвращается, как только
строки challenge и outbox durably закоммитены. Поле в теле — это
канонический канал, потому что Safari молча сбрасывает выставляемые
из JS заголовки `Accept-Language`; клиентам не на Safari достаточно
одного заголовка.
### 1.3 Подтверждение вызова ### 1.3 Подтверждение вызова
+18 -3
View File
@@ -56,9 +56,16 @@ type SendEmailCodeInput struct {
// code challenge. // code challenge.
Email string `json:"email"` Email string `json:"email"`
// PreferredLanguage stores the canonical BCP 47 language tag derived from // Locale is the optional BCP 47 language tag the caller wants the
// the public Accept-Language header for upstream auth-mail localization and // auth-mail in. The body field is the canonical channel because Safari
// create-only user registration context. // silently drops JS-set Accept-Language headers; when set, it overrides
// the request Accept-Language for preferred-language resolution.
Locale string `json:"locale,omitempty"`
// PreferredLanguage stores the canonical BCP 47 language tag derived
// from Locale (preferred) or the Accept-Language header (fallback) for
// upstream auth-mail localization and create-only user registration
// context.
PreferredLanguage string `json:"-"` PreferredLanguage string `json:"-"`
} }
@@ -209,7 +216,15 @@ func handleSendEmailCode(authService AuthServiceClient, timeout time.Duration) g
abortInvalidRequest(c, err.Error()) abortInvalidRequest(c, err.Error())
return return
} }
// Body locale wins over the request header so Safari clients,
// which cannot set Accept-Language from JavaScript, can still
// pick a non-system mail language. Empty / malformed values
// fall through resolvePreferredLanguage to the default.
if strings.TrimSpace(input.Locale) != "" {
input.PreferredLanguage = resolvePreferredLanguage(input.Locale)
} else {
input.PreferredLanguage = resolvePreferredLanguage(c.Request.Header.Get("Accept-Language")) input.PreferredLanguage = resolvePreferredLanguage(c.Request.Header.Get("Accept-Language"))
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout) callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel() defer cancel()
@@ -52,6 +52,64 @@ func TestSendEmailCodeHandlerSuccess(t *testing.T) {
assert.Equal(t, PublicRouteClassPublicAuth, authService.sendEmailCodeRouteClass) assert.Equal(t, PublicRouteClassPublicAuth, authService.sendEmailCodeRouteClass)
} }
func TestSendEmailCodeHandlerBodyLocaleOverridesHeader(t *testing.T) {
t.Parallel()
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{
ChallengeID: "challenge-456",
},
}
handler := newPublicHandler(ServerDependencies{AuthService: authService})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
strings.NewReader(`{"email":"pilot@example.com","locale":"ru"}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "fr-FR")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, SendEmailCodeInput{
Email: "pilot@example.com",
Locale: "ru",
PreferredLanguage: "ru",
}, authService.sendEmailCodeInput)
}
func TestSendEmailCodeHandlerEmptyBodyLocaleFallsBackToHeader(t *testing.T) {
t.Parallel()
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{
ChallengeID: "challenge-789",
},
}
handler := newPublicHandler(ServerDependencies{AuthService: authService})
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/public/auth/send-email-code",
strings.NewReader(`{"email":"pilot@example.com","locale":" "}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "ru-RU")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, SendEmailCodeInput{
Email: "pilot@example.com",
Locale: " ",
PreferredLanguage: "ru-RU",
}, authService.sendEmailCodeInput)
}
func TestConfirmEmailCodeHandlerSuccess(t *testing.T) { func TestConfirmEmailCodeHandlerSuccess(t *testing.T) {
t.Parallel() t.Parallel()
+16 -4
View File
@@ -134,10 +134,12 @@ paths:
that must later be confirmed through that must later be confirmed through
`POST /api/v1/public/auth/confirm-email-code`. `POST /api/v1/public/auth/confirm-email-code`.
The JSON body stays unchanged. Callers may additionally supply the Callers select the auth-mail locale through the optional
standard `Accept-Language` header so the gateway can derive the `locale` field on the JSON body, which takes priority over the
auth-mail locale and first-login preferred-language candidate. Missing request `Accept-Language` header. The body field is the canonical
or unsupported values fall back to `en`. channel because Safari silently drops JS-set `Accept-Language`
headers; non-Safari clients can still rely on the header alone.
Missing or unsupported values fall back to `en`.
This route is unauthenticated and classified as `public_auth`. This route is unauthenticated and classified as `public_auth`.
Public REST anti-abuse applies a per-IP bucket derived from Public REST anti-abuse applies a per-IP bucket derived from
@@ -302,6 +304,16 @@ components:
type: string type: string
description: Single client e-mail address that should receive the login code. description: Single client e-mail address that should receive the login code.
format: email format: email
locale:
type: string
description: |
Optional BCP 47 language tag the caller prefers for the
delivered code. The body field is the canonical channel
because Safari silently drops JS-set Accept-Language
headers; when set, it overrides the request
`Accept-Language` for preferred-language resolution.
Empty / malformed values fall back to the header, which
in turn falls back to `en`.
SendEmailCodeResponse: SendEmailCodeResponse:
type: object type: object
additionalProperties: false additionalProperties: false
+3
View File
@@ -64,6 +64,7 @@ ui/
├── buf.gen.yaml local-plugin TS Protobuf-ES generator ├── buf.gen.yaml local-plugin TS Protobuf-ES generator
├── docs/ topic-based design notes ├── docs/ topic-based design notes
│ ├── auth-flow.md email-code login, session store, revocation │ ├── auth-flow.md email-code login, session store, revocation
│ ├── i18n.md translation primitive, native-name picker, extensibility
│ ├── storage.md web KeyStore/Cache, IDB schema, baseline │ ├── storage.md web KeyStore/Cache, IDB schema, baseline
│ ├── testing.md per-PR / release test tiers │ ├── testing.md per-PR / release test tiers
│ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget │ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget
@@ -83,6 +84,8 @@ Linked topic docs:
- [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login, - [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login,
session store state machine, revocation watcher. session store state machine, revocation watcher.
- [`docs/i18n.md`](docs/i18n.md) — translation primitive, native-name
language picker, recipe for adding a new locale.
- [`docs/storage.md`](docs/storage.md) — web KeyStore/Cache, - [`docs/storage.md`](docs/storage.md) — web KeyStore/Cache,
IndexedDB schema, browser baseline. IndexedDB schema, browser baseline.
- [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build, - [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build,
+18
View File
@@ -125,6 +125,24 @@ the stream the moment it observes a `session_invalidation` push
event from backend, and the watcher reacts on the next event-loop event from backend, and the watcher reacts on the next event-loop
tick. tick.
## Localisation
The login form, the root layout's blocker page, and the lobby
placeholder go through the i18n primitive in `src/lib/i18n/`. The
language picker on `/login` lists every entry in
`SUPPORTED_LOCALES` by its native name and is initialised from
`navigator.languages` (web) with `en` as the fallback. Picking a
different language re-renders the form in place and is forwarded
to the gateway in the JSON body of `send-email-code` (`locale`
field) — the body channel is the canonical one because Safari
drops JS-set `Accept-Language` headers. See
[`i18n.md`](i18n.md) for the architecture and the recipe for
adding a new language.
The locale is **not** persisted between page reloads; detection
runs again on every visit. Phase 35's full polish pass will
revisit persistence and add message-format pluralisation.
## Configuration ## Configuration
Build-time environment, read by `lib/env.ts`: Build-time environment, read by `lib/env.ts`:
+143
View File
@@ -0,0 +1,143 @@
# i18n (UI)
The UI client ships with a minimal locale primitive used by the
phase-7 login form, the root layout, and the lobby placeholder. The
goal is just enough infrastructure to translate user-visible
strings, switch the active language at runtime, and forward the
caller's choice to the gateway. Phase 35 will swap this primitive
for a fuller solution once message-format pluralisation, lazy
loading, and translator workflows become necessary; until then,
the surface here covers every authenticated and unauthenticated
screen the client renders.
## Surface
```
src/lib/i18n/
├── index.svelte.ts # I18nStore singleton, types, SUPPORTED_LOCALES
└── locales/
├── en.ts # English dictionary (default, source of truth)
└── ru.ts # Russian dictionary (mirrors en.ts keys)
```
The exported singleton (`i18n`) is a Svelte 5 runes class with one
reactive field, `locale`, and a `t(key, params?)` lookup. Components
read translations through `i18n.t('login.title')` and re-render
automatically when `i18n.locale` changes.
The runes singleton is a `.svelte.ts` file because Svelte 5 only
processes `$state` runes inside `.svelte`, `.svelte.js`, and
`.svelte.ts` modules.
## Adding a language
Two-step change inside `src/lib/i18n/`:
1. Drop `locales/<bcp47-primary-subtag>.ts` mirroring the shape of
`locales/en.ts`. The TypeScript signature on each non-English
file is `Record<keyof typeof en, string>`, so the compiler
refuses to build until every key in `en.ts` is translated.
2. Register the new file in the `SUPPORTED_LOCALES` array in
`index.svelte.ts`. That single list drives the language picker
(UI) and the runtime lookup table.
For example, adding French:
```ts
// src/lib/i18n/locales/fr.ts
import type en from "./en";
const fr: Record<keyof typeof en, string> = {
"common.language": "langue",
/* …translate every other key… */
};
export default fr;
```
```ts
// src/lib/i18n/index.svelte.ts
import frTranslations from "./locales/fr";
export type Locale = "en" | "ru" | "fr";
export const SUPPORTED_LOCALES: readonly LocaleEntry[] = [
{ code: "en", nativeName: "English", translations: enTranslations },
{ code: "ru", nativeName: "Русский", translations: ruTranslations },
{ code: "fr", nativeName: "Français", translations: frTranslations },
];
```
No other code change is required: the picker, the detection helper,
the `t()` function and the gateway forwarding all derive from
`SUPPORTED_LOCALES`.
## Detection
`detectInitialLocale(preferences?)` returns the first
`SUPPORTED_LOCALES` entry whose `code` matches the primary subtag of
any preference, or `DEFAULT_LOCALE` (English) when nothing matches.
The web target calls it without arguments, in which case the helper
reads `navigator.languages` (or `navigator.language` as fallback).
Native wrappers (Wails, Capacitor) will pass their system locale
once Phase 31/32 lands; the helper is platform-agnostic by design.
The detection runs once at module load — there is no asynchronous
init step. Callers that mutate the locale (e.g. the language picker
on `/login`) call `i18n.setLocale(next)` directly. The choice is
**not** persisted between page reloads in Phase 7; the next visit
re-runs detection. Persistence is a phase-35 concern.
## Forwarding the locale to the gateway
The login form passes the active `i18n.locale` to
`sendEmailCode(baseUrl, email, { locale })`. The auth API places
the value inside the JSON body (`locale` field) rather than the
`Accept-Language` header:
```ts
await sendEmailCode(GATEWAY_BASE_URL, trimmed, { locale: i18n.locale });
```
The body field is the canonical channel because Safari/WebKit
silently drops JS-set `Accept-Language` headers (a long-standing
WebKit fingerprinting mitigation). The gateway reads the body
field with priority over the request `Accept-Language`, and
non-Safari clients can still rely on the header alone — the gateway
treats body and header as a single fallback chain. See
`gateway/internal/restapi/public_auth.go` for the resolution path
and `docs/FUNCTIONAL.md` §1.2 for the contract.
The `confirm-email-code` endpoint does **not** carry the locale.
Per `docs/FUNCTIONAL.md` §1.3, the preferred language is captured
at challenge issuance and replayed from the challenge row.
## Key conventions
- Dotted keys grouped by feature area: `login.*`, `lobby.*`,
`common.*`. New screens own their own prefix.
- Templates may carry simple `{name}` placeholders. The `t()`
helper substitutes them with the caller-provided value via plain
string replacement; values are written to the DOM unescaped, so
callers must feed user-safe strings.
- Lookup falls back to the default locale and finally to the literal
key when a key is missing in the active locale. The TypeScript
signature on each locale file enforces complete coverage at build
time, so runtime fallback is the safety net for a freshly added
language that has not finished its translation pass.
- The translation file is the single source of truth — components
never hardcode user-visible English text; everything goes through
`i18n.t(...)`.
## Testing
- `tests/i18n.test.ts` covers `detectInitialLocale`,
`i18n.setLocale`, parameter interpolation, and the unknown-key
fallback.
- `tests/login-page.test.ts` asserts the language picker renders
with native names, switching the locale re-renders the form
text, and `sendEmailCode` receives the active locale.
- `tests/auth-api.test.ts` asserts the locale is forwarded through
the JSON body of `send-email-code`.
- `tests/e2e/auth-flow.spec.ts` covers the dropdown-driven switch
end-to-end on every Playwright project, including Safari (where
Accept-Language is unsettable from JS).
+26 -4
View File
@@ -22,6 +22,20 @@ export interface SendEmailCodeResult {
challengeId: string; challengeId: string;
} }
export interface SendEmailCodeOptions {
/**
* locale is forwarded inside the JSON body and read by the
* gateway in preference to the request `Accept-Language` header.
* The body field is the canonical channel because Safari/WebKit
* silently drops JS-set `Accept-Language` headers, while the
* body round-trips correctly on every supported engine. When the
* caller omits this option the browser-default Accept-Language
* remains the gateway's only signal and the auth-mail uses the
* system locale.
*/
locale?: string;
}
export interface ConfirmEmailCodeInput { export interface ConfirmEmailCodeInput {
challengeId: string; challengeId: string;
code: string; code: string;
@@ -61,24 +75,32 @@ export class AuthError extends Error {
export async function sendEmailCode( export async function sendEmailCode(
baseUrl: string, baseUrl: string,
email: string, email: string,
options?: SendEmailCodeOptions,
): Promise<SendEmailCodeResult> { ): Promise<SendEmailCodeResult> {
const requestBody: Record<string, string> = { email };
if (options?.locale !== undefined && options.locale !== "") {
requestBody.locale = options.locale;
}
const response = await fetch(joinUrl(baseUrl, SEND_EMAIL_CODE_PATH), { const response = await fetch(joinUrl(baseUrl, SEND_EMAIL_CODE_PATH), {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ email }), body: JSON.stringify(requestBody),
}); });
if (!response.ok) { if (!response.ok) {
throw await readAuthError(response); throw await readAuthError(response);
} }
const body = (await response.json()) as { challenge_id?: unknown }; const responseBody = (await response.json()) as { challenge_id?: unknown };
if (typeof body.challenge_id !== "string" || body.challenge_id.length === 0) { if (
typeof responseBody.challenge_id !== "string" ||
responseBody.challenge_id.length === 0
) {
throw new AuthError( throw new AuthError(
"internal_error", "internal_error",
"gateway returned a malformed send-email-code response", "gateway returned a malformed send-email-code response",
response.status, response.status,
); );
} }
return { challengeId: body.challenge_id }; return { challengeId: responseBody.challenge_id };
} }
/** /**
+150
View File
@@ -0,0 +1,150 @@
// Lightweight i18n primitive used by the login form, the root
// layout, and the lobby placeholder. The translation table is a
// per-locale flat dictionary keyed by dotted strings; lookup falls
// back to the default (English) locale when a key is missing.
//
// Adding a new language is a two-step change inside this folder:
// 1. drop a `locales/<bcp47>.ts` file mirroring the shape of
// `locales/en.ts` (TypeScript enforces matching keys via the
// `Record<keyof typeof en, string>` annotation in `ru.ts`);
// 2. register the file in `SUPPORTED_LOCALES` below — that single
// list drives the language picker UI and the runtime lookup
// table at the same time.
//
// The locale state is exposed through a Svelte 5 runes singleton
// (`i18n`) so components stay reactive without ceremony:
// `<p>{i18n.t('login.title')}</p>` re-renders whenever
// `i18n.locale` changes. Phase 35 will swap this primitive for a
// fuller solution once message-format pluralisation and lazy
// loading become necessary.
import enTranslations from "./locales/en";
import ruTranslations from "./locales/ru";
export type Locale = "en" | "ru";
export type TranslationKey = keyof typeof enTranslations;
export interface LocaleEntry {
readonly code: Locale;
readonly nativeName: string;
readonly translations: Readonly<Record<TranslationKey, string>>;
}
export const SUPPORTED_LOCALES: readonly LocaleEntry[] = [
{
code: "en",
nativeName: "English",
translations: enTranslations,
},
{
code: "ru",
nativeName: "Русский",
translations: ruTranslations,
},
];
export const DEFAULT_LOCALE: Locale = "en";
const TRANSLATIONS_BY_LOCALE: Record<
Locale,
Readonly<Record<TranslationKey, string>>
> = SUPPORTED_LOCALES.reduce(
(acc, entry) => {
acc[entry.code] = entry.translations;
return acc;
},
{} as Record<Locale, Readonly<Record<TranslationKey, string>>>,
);
/**
* detectInitialLocale returns the best supported locale match for
* the supplied BCP 47 preference list. The web target passes
* `navigator.languages`; native wrappers pass the system locale
* (one entry). The first preference whose primary subtag matches
* a `SUPPORTED_LOCALES` entry wins; otherwise [DEFAULT_LOCALE].
*/
export function detectInitialLocale(
preferences?: readonly string[],
): Locale {
const prefs = preferences ?? readBrowserPreferences();
for (const tag of prefs) {
const primary = primarySubtag(tag);
if (primary === null) {
continue;
}
const found = SUPPORTED_LOCALES.find((entry) => entry.code === primary);
if (found !== undefined) {
return found.code;
}
}
return DEFAULT_LOCALE;
}
function readBrowserPreferences(): readonly string[] {
if (typeof navigator === "undefined") {
return [];
}
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
return navigator.languages;
}
if (typeof navigator.language === "string" && navigator.language !== "") {
return [navigator.language];
}
return [];
}
function primarySubtag(tag: string): Locale | null {
const trimmed = tag.trim().toLowerCase();
if (trimmed.length === 0) {
return null;
}
const code = trimmed.split(/[-_]/)[0] ?? "";
return isLocale(code) ? code : null;
}
function isLocale(value: string): value is Locale {
return SUPPORTED_LOCALES.some((entry) => entry.code === value);
}
class I18nStore {
locale: Locale = $state(detectInitialLocale());
/**
* setLocale changes the active locale. Components reading
* `i18n.t(...)` re-render automatically through the rune.
*/
setLocale(next: Locale): void {
this.locale = next;
}
/**
* t looks up `key` in the active locale, falling back to the
* default locale when the key is missing. `params` is an optional
* `{name -> value}` map; placeholders in the template (`{name}`)
* are replaced literally with no escaping — callers are expected
* to feed user-safe values.
*/
t(key: TranslationKey, params?: Record<string, string>): string {
const active = TRANSLATIONS_BY_LOCALE[this.locale];
const fallback = TRANSLATIONS_BY_LOCALE[DEFAULT_LOCALE];
const template = active[key] ?? fallback[key] ?? key;
if (params === undefined) {
return template;
}
return template.replace(/\{(\w+)\}/g, (match, name: string) => {
const value = params[name];
return value === undefined ? match : value;
});
}
/**
* resetForTests forces the singleton back to its module-load
* state. Production code never calls this; the Vitest harness
* uses it to keep cases independent.
*/
resetForTests(initial: Locale = detectInitialLocale()): void {
this.locale = initial;
}
}
export const i18n = new I18nStore();
+40
View File
@@ -0,0 +1,40 @@
// English translation dictionary. Keys are dotted strings grouped
// by feature area (`login.*`, `lobby.*`, `common.*`); values are
// the user-visible text. Adding a new key here also requires adding
// it to every other locale dictionary in this folder, otherwise the
// `t()` helper falls back to English at runtime.
const en = {
"common.language": "language",
"common.loading": "loading…",
"common.browser_not_supported_title": "browser not supported",
"common.browser_not_supported_body":
"Galaxy requires Ed25519 in WebCrypto. See supported browsers.",
"login.title": "sign in to Galaxy",
"login.email_label": "email",
"login.email_required": "email must not be empty",
"login.send_code": "send code",
"login.sending": "sending…",
"login.code_label": "code",
"login.code_required": "code must not be empty",
"login.code_sent_to": "code sent to {email}",
"login.verify": "verify",
"login.verifying": "verifying…",
"login.send_new_code": "send a new code",
"login.change_email": "change email",
"login.challenge_expired":
"challenge expired, please request a new code",
"login.code_expired_or_used":
"code expired or already used, please request a new one",
"login.device_key_not_ready":
"device key is not ready, please reload the page",
"lobby.title": "you are logged in",
"lobby.device_session_id_label": "device session id",
"lobby.greeting": "hello, {name}!",
"lobby.account_loading": "loading account…",
"lobby.logout": "logout",
} as const;
export default en;
+41
View File
@@ -0,0 +1,41 @@
// Russian translation dictionary. The keys are identical to the
// English dictionary in `en.ts`; the values are the human Russian
// text. Adding a new key requires updating every locale file in
// this folder so the `t()` helper does not fall back to English.
import type en from "./en";
const ru: Record<keyof typeof en, string> = {
"common.language": "язык",
"common.loading": "загрузка…",
"common.browser_not_supported_title": "браузер не поддерживается",
"common.browser_not_supported_body":
"Galaxy требует поддержки Ed25519 в WebCrypto. См. список поддерживаемых браузеров.",
"login.title": "вход в Galaxy",
"login.email_label": "электронная почта",
"login.email_required": "адрес не должен быть пустым",
"login.send_code": "отправить код",
"login.sending": "отправляем…",
"login.code_label": "код",
"login.code_required": "код не должен быть пустым",
"login.code_sent_to": "код отправлен на {email}",
"login.verify": "подтвердить",
"login.verifying": "проверяем…",
"login.send_new_code": "отправить новый код",
"login.change_email": "изменить адрес",
"login.challenge_expired":
"запрос устарел, запросите новый код",
"login.code_expired_or_used":
"код устарел или уже использован, запросите новый",
"login.device_key_not_ready":
"ключ устройства ещё не готов, перезагрузите страницу",
"lobby.title": "вы вошли в систему",
"lobby.device_session_id_label": "идентификатор сессии устройства",
"lobby.greeting": "здравствуйте, {name}!",
"lobby.account_loading": "загрузка профиля…",
"lobby.logout": "выйти",
};
export default ru;
+4 -9
View File
@@ -2,6 +2,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
import { startRevocationWatcher } from "$lib/revocation-watcher"; import { startRevocationWatcher } from "$lib/revocation-watcher";
@@ -45,18 +46,12 @@
{#if session.status === "loading"} {#if session.status === "loading"}
<main class="status"> <main class="status">
<p>loading</p> <p>{i18n.t("common.loading")}</p>
</main> </main>
{:else if session.status === "unsupported"} {:else if session.status === "unsupported"}
<main class="status"> <main class="status">
<h1>browser not supported</h1> <h1>{i18n.t("common.browser_not_supported_title")}</h1>
<p> <p>{i18n.t("common.browser_not_supported_body")}</p>
Galaxy requires Ed25519 in WebCrypto. The minimum supported browser
versions are listed in the
<a href="https://github.com/galaxy/galaxy/blob/main/ui/docs/storage.md"
>storage topic doc</a
>.
</p>
</main> </main>
{:else} {:else}
{@render children()} {@render children()}
+10 -8
View File
@@ -3,6 +3,7 @@
import { createEdgeGatewayClient } from "../../api/connect"; import { createEdgeGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client"; import { GalaxyClient } from "../../api/galaxy-client";
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env"; import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { i18n } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index"; import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
@@ -64,22 +65,23 @@
</script> </script>
<main> <main>
<h1>you are logged in</h1> <h1>{i18n.t("lobby.title")}</h1>
<p> <p>
device session id: <code data-testid="device-session-id" {i18n.t("lobby.device_session_id_label")}:
>{session.deviceSessionId ?? ""}</code <code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
>
</p> </p>
{#if accountLoading} {#if accountLoading}
<p>loading account…</p> <p>{i18n.t("lobby.account_loading")}</p>
{:else if displayName !== null} {:else if displayName !== null}
<p> <p data-testid="account-greeting">
hello, <span data-testid="account-display-name">{displayName}</span>! {i18n.t("lobby.greeting", { name: displayName })}
</p> </p>
{:else if accountError !== null} {:else if accountError !== null}
<p role="alert" data-testid="account-error">{accountError}</p> <p role="alert" data-testid="account-error">{accountError}</p>
{/if} {/if}
<button onclick={logout} data-testid="lobby-logout">logout</button> <button onclick={logout} data-testid="lobby-logout">
{i18n.t("lobby.logout")}
</button>
</main> </main>
<style> <style>
+128 -22
View File
@@ -6,6 +6,7 @@
sendEmailCode, sendEmailCode,
} from "../../api/auth"; } from "../../api/auth";
import { GATEWAY_BASE_URL } from "$lib/env"; import { GATEWAY_BASE_URL } from "$lib/env";
import { i18n, SUPPORTED_LOCALES } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
type Step = "email" | "code"; type Step = "email" | "code";
@@ -32,13 +33,15 @@
if (pending) return; if (pending) return;
const trimmed = email.trim(); const trimmed = email.trim();
if (trimmed.length === 0) { if (trimmed.length === 0) {
error = "email must not be empty"; error = i18n.t("login.email_required");
return; return;
} }
pending = true; pending = true;
error = null; error = null;
try { try {
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed); const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
locale: i18n.locale,
});
challengeId = result.challengeId; challengeId = result.challengeId;
code = ""; code = "";
step = "code"; step = "code";
@@ -54,16 +57,16 @@
if (pending) return; if (pending) return;
const trimmedCode = code.trim(); const trimmedCode = code.trim();
if (trimmedCode.length === 0) { if (trimmedCode.length === 0) {
error = "code must not be empty"; error = i18n.t("login.code_required");
return; return;
} }
if (challengeId === null) { if (challengeId === null) {
error = "challenge expired, please request a new code"; error = i18n.t("login.challenge_expired");
step = "email"; step = "email";
return; return;
} }
if (session.keypair === null) { if (session.keypair === null) {
error = "device key is not ready, please reload the page"; error = i18n.t("login.device_key_not_ready");
return; return;
} }
pending = true; pending = true;
@@ -82,7 +85,7 @@
challengeId = null; challengeId = null;
code = ""; code = "";
step = "email"; step = "email";
error = "code expired or already used, please request a new one"; error = i18n.t("login.code_expired_or_used");
} else { } else {
error = describe(err); error = describe(err);
} }
@@ -101,7 +104,9 @@
pending = true; pending = true;
error = null; error = null;
try { try {
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed); const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
locale: i18n.locale,
});
challengeId = result.challengeId; challengeId = result.challengeId;
code = ""; code = "";
} catch (err) { } catch (err) {
@@ -120,16 +125,68 @@
</script> </script>
<main> <main>
<h1>sign in to Galaxy</h1> <header>
<h1>{i18n.t("login.title")}</h1>
<div class="language-picker">
<svg
class="globe"
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
focusable="false"
>
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M3 12h18"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M12 3a13 13 0 0 1 0 18M12 3a13 13 0 0 0 0 18"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
<label class="visually-hidden" for="login-language-select">
{i18n.t("common.language")}
</label>
<select
id="login-language-select"
data-testid="login-language-select"
bind:value={i18n.locale}
>
{#each SUPPORTED_LOCALES as locale (locale.code)}
<option value={locale.code}>{locale.nativeName}</option>
{/each}
</select>
</div>
</header>
{#if step === "email"} {#if step === "email"}
<form onsubmit={submitEmail} aria-busy={pending}> <form
onsubmit={submitEmail}
aria-busy={pending}
autocomplete="off"
>
<label> <label>
email {i18n.t("login.email_label")}
<input <input
type="email" type="email"
name="email" name="galaxy-login-email"
autocomplete="email" autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={email} bind:value={email}
disabled={pending} disabled={pending}
required required
@@ -141,19 +198,28 @@
disabled={pending} disabled={pending}
data-testid="login-email-submit" data-testid="login-email-submit"
> >
{pending ? "sending" : "send code"} {pending ? i18n.t("login.sending") : i18n.t("login.send_code")}
</button> </button>
</form> </form>
{:else} {:else}
<form onsubmit={submitCode} aria-busy={pending}> <form
<p data-testid="login-code-target">code sent to {email}</p> onsubmit={submitCode}
aria-busy={pending}
autocomplete="off"
>
<p data-testid="login-code-target">
{i18n.t("login.code_sent_to", { email })}
</p>
<label> <label>
code {i18n.t("login.code_label")}
<input <input
type="text" type="text"
name="code" name="galaxy-login-code"
inputmode="numeric" inputmode="numeric"
autocomplete="one-time-code" autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={code} bind:value={code}
disabled={pending} disabled={pending}
required required
@@ -161,7 +227,7 @@
/> />
</label> </label>
<button type="submit" disabled={pending} data-testid="login-code-submit"> <button type="submit" disabled={pending} data-testid="login-code-submit">
{pending ? "verifying" : "verify"} {pending ? i18n.t("login.verifying") : i18n.t("login.verify")}
</button> </button>
<div class="secondary"> <div class="secondary">
<button <button
@@ -170,7 +236,7 @@
disabled={pending} disabled={pending}
data-testid="login-resend" data-testid="login-resend"
> >
send a new code {i18n.t("login.send_new_code")}
</button> </button>
<button <button
type="button" type="button"
@@ -178,7 +244,7 @@
disabled={pending} disabled={pending}
data-testid="login-change-email" data-testid="login-change-email"
> >
change email {i18n.t("login.change_email")}
</button> </button>
</div> </div>
</form> </form>
@@ -196,6 +262,46 @@
max-width: 32rem; max-width: 32rem;
} }
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
header h1 {
margin: 0;
}
.language-picker {
display: inline-flex;
align-items: center;
gap: 0.5rem;
opacity: 0.85;
}
.globe {
flex: none;
}
.language-picker select {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -203,7 +309,7 @@
margin-top: 1.5rem; margin-top: 1.5rem;
} }
label { form > label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
+20
View File
@@ -55,6 +55,26 @@ describe("sendEmailCode", () => {
); );
}); });
test("forwards the locale option in the JSON body", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: "ch-1" }));
await sendEmailCode(BASE_URL, "pilot@example.com", { locale: "ru" });
const [, init] = fetchSpy.mock.calls[0]!;
expect(init?.headers).toEqual({ "content-type": "application/json" });
expect(JSON.parse(init?.body as string)).toEqual({
email: "pilot@example.com",
locale: "ru",
});
});
test("omits the locale field when no locale is provided", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: "ch-1" }));
await sendEmailCode(BASE_URL, "pilot@example.com");
const [, init] = fetchSpy.mock.calls[0]!;
expect(JSON.parse(init?.body as string)).toEqual({
email: "pilot@example.com",
});
});
test("throws AuthError carrying gateway code and message on 400", async () => { test("throws AuthError carrying gateway code and message on 400", async () => {
fetchSpy.mockResolvedValueOnce( fetchSpy.mockResolvedValueOnce(
jsonResponse(400, { jsonResponse(400, {
+45 -3
View File
@@ -143,7 +143,7 @@ test.describe("Phase 7 — auth flow", () => {
await expect(page.getByTestId("device-session-id")).toHaveText( await expect(page.getByTestId("device-session-id")).toHaveText(
"dev-test-1", "dev-test-1",
); );
await expect(page.getByTestId("account-display-name")).toHaveText("Pilot"); await expect(page.getByTestId("account-greeting")).toContainText("Pilot");
mocks.pendingSubscribes.forEach((resolve) => resolve()); mocks.pendingSubscribes.forEach((resolve) => resolve());
}); });
@@ -153,7 +153,7 @@ test.describe("Phase 7 — auth flow", () => {
}) => { }) => {
const mocks = await mockGatewayHappyPath(page, "Pilot"); const mocks = await mockGatewayHappyPath(page, "Pilot");
await completeLogin(page); await completeLogin(page);
await expect(page.getByTestId("account-display-name")).toBeVisible(); await expect(page.getByTestId("account-greeting")).toBeVisible();
await page.reload(); await page.reload();
await expect(page).toHaveURL(/\/lobby$/); await expect(page).toHaveURL(/\/lobby$/);
@@ -169,7 +169,7 @@ test.describe("Phase 7 — auth flow", () => {
}) => { }) => {
const mocks = await mockGatewayHappyPath(page, "Pilot"); const mocks = await mockGatewayHappyPath(page, "Pilot");
await completeLogin(page); await completeLogin(page);
await expect(page.getByTestId("account-display-name")).toBeVisible(); await expect(page.getByTestId("account-greeting")).toBeVisible();
// Fire all pending SubscribeEvents requests with an empty 200 // Fire all pending SubscribeEvents requests with an empty 200
// response. Connect-Web's server-streaming reader sees no frames // response. Connect-Web's server-streaming reader sees no frames
@@ -182,6 +182,48 @@ test.describe("Phase 7 — auth flow", () => {
expect(Date.now() - releaseAt).toBeLessThan(1500); expect(Date.now() - releaseAt).toBeLessThan(1500);
}); });
test("language picker switches the form text and forwards the locale", async ({
page,
}) => {
const sendRequests: Array<{ body: Record<string, unknown> }> = [];
await page.route(
"**/api/v1/public/auth/send-email-code",
async (route) => {
const raw = route.request().postData() ?? "";
sendRequests.push({
body: JSON.parse(raw) as Record<string, unknown>,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ challenge_id: "ch-test-2" }),
});
},
);
await page.goto("/login");
await expect(page.getByTestId("login-email-submit")).toHaveText(
"send code",
);
await page
.getByTestId("login-language-select")
.selectOption("ru");
await expect(page.getByTestId("login-email-submit")).toHaveText(
"отправить код",
);
await page.getByTestId("login-email-input").fill("pilot@example.com");
await page.getByTestId("login-email-submit").click();
await expect(page.getByTestId("login-code-input")).toBeVisible();
expect(sendRequests).toHaveLength(1);
expect(sendRequests[0]!.body).toEqual({
email: "pilot@example.com",
locale: "ru",
});
});
test("browser without WebCrypto Ed25519 shows the not-supported blocker", async ({ test("browser without WebCrypto Ed25519 shows the not-supported blocker", async ({
page, page,
}) => { }) => {
+107
View File
@@ -0,0 +1,107 @@
// Unit tests for the lightweight i18n primitive in
// `src/lib/i18n/index.svelte.ts`. The locale singleton is reset
// between cases through `resetForTests`; the default constructor
// derives the locale from JSDOM's `navigator.language`, so the
// reset takes an explicit value when a case needs a deterministic
// starting state.
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
DEFAULT_LOCALE,
SUPPORTED_LOCALES,
detectInitialLocale,
i18n,
type Locale,
} from "../src/lib/i18n/index.svelte";
beforeEach(() => {
i18n.resetForTests("en");
});
afterEach(() => {
i18n.resetForTests("en");
});
describe("detectInitialLocale", () => {
test("matches the first supported primary subtag", () => {
expect(detectInitialLocale(["ru-RU", "en-US"])).toBe("ru");
expect(detectInitialLocale(["en-GB", "ru"])).toBe("en");
expect(detectInitialLocale(["RU"])).toBe("ru");
});
test("skips unsupported tags and falls back to the default locale", () => {
expect(detectInitialLocale(["fr-FR", "de-DE", "ja"])).toBe(DEFAULT_LOCALE);
expect(detectInitialLocale([])).toBe(DEFAULT_LOCALE);
});
test("ignores whitespace and casing", () => {
expect(detectInitialLocale([" En-us "])).toBe("en");
expect(detectInitialLocale(["RU_ru"])).toBe("ru");
});
});
describe("SUPPORTED_LOCALES", () => {
test("each entry exposes a code, native name and translation table", () => {
for (const entry of SUPPORTED_LOCALES) {
expect(entry.code).toMatch(/^[a-z]{2}$/);
expect(entry.nativeName.length).toBeGreaterThan(0);
expect(entry.translations["login.title"].length).toBeGreaterThan(0);
}
});
test("native names cover the two phase-7 locales", () => {
const codes = SUPPORTED_LOCALES.map((l) => l.code);
const names = SUPPORTED_LOCALES.map((l) => l.nativeName);
expect(codes).toEqual(["en", "ru"]);
expect(names).toEqual(["English", "Русский"]);
});
});
describe("i18n.t", () => {
test("returns the active locale's translation", () => {
i18n.setLocale("en");
expect(i18n.t("login.title")).toBe("sign in to Galaxy");
i18n.setLocale("ru");
expect(i18n.t("login.title")).toBe("вход в Galaxy");
});
test("returns the key itself for an unknown identifier", () => {
// `t` is typed `TranslationKey`, but bracket access at runtime
// gracefully handles unknown keys — the fallback chain tries
// the active locale, then the default locale, then the literal
// key. This is the safety net for a future locale that adds
// keys before another locale catches up.
i18n.setLocale("en");
expect(
i18n.t("does.not.exist" as unknown as Parameters<typeof i18n.t>[0]),
).toBe("does.not.exist");
});
test("interpolates {placeholder} parameters", () => {
i18n.setLocale("en");
expect(i18n.t("login.code_sent_to", { email: "x@y.z" })).toBe(
"code sent to x@y.z",
);
i18n.setLocale("ru");
expect(i18n.t("login.code_sent_to", { email: "x@y.z" })).toBe(
"код отправлен на x@y.z",
);
});
test("leaves unresolved placeholders intact", () => {
i18n.setLocale("en");
expect(i18n.t("login.code_sent_to")).toContain("{email}");
expect(i18n.t("login.code_sent_to", { other: "x" })).toContain(
"{email}",
);
});
});
describe("i18n.setLocale", () => {
test("setLocale updates the reactive state", () => {
i18n.setLocale("en");
expect(i18n.locale).toBe<Locale>("en");
i18n.setLocale("ru");
expect(i18n.locale).toBe<Locale>("ru");
});
});
+65
View File
@@ -18,6 +18,7 @@ import {
import type { IDBPDatabase } from "idb"; import type { IDBPDatabase } from "idb";
import { AuthError } from "../src/api/auth"; import { AuthError } from "../src/api/auth";
import { i18n } from "../src/lib/i18n/index.svelte";
import { session } from "../src/lib/session-store.svelte"; import { session } from "../src/lib/session-store.svelte";
import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb"; import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb";
import { IDBCache } from "../src/platform/store/idb-cache"; import { IDBCache } from "../src/platform/store/idb-cache";
@@ -54,6 +55,7 @@ beforeEach(async () => {
session.resetForTests(); session.resetForTests();
session.setStoreLoaderForTests(async () => store); session.setStoreLoaderForTests(async () => store);
await session.init(); await session.init();
i18n.resetForTests("en");
sendEmailCodeSpy.mockReset(); sendEmailCodeSpy.mockReset();
confirmEmailCodeSpy.mockReset(); confirmEmailCodeSpy.mockReset();
}); });
@@ -62,6 +64,7 @@ afterEach(async () => {
sendEmailCodeSpy.mockReset(); sendEmailCodeSpy.mockReset();
confirmEmailCodeSpy.mockReset(); confirmEmailCodeSpy.mockReset();
session.resetForTests(); session.resetForTests();
i18n.resetForTests("en");
db.close(); db.close();
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const req = indexedDB.deleteDatabase(dbName); const req = indexedDB.deleteDatabase(dbName);
@@ -91,6 +94,7 @@ describe("login page", () => {
expect(sendEmailCodeSpy).toHaveBeenCalledWith( expect(sendEmailCodeSpy).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
"pilot@example.com", "pilot@example.com",
{ locale: "en" },
); );
expect(ui.getByTestId("login-code-input")).toBeInTheDocument(); expect(ui.getByTestId("login-code-input")).toBeInTheDocument();
}); });
@@ -222,4 +226,65 @@ describe("login page", () => {
expect(ui.getByTestId("login-email-input")).toBeInTheDocument(); expect(ui.getByTestId("login-email-input")).toBeInTheDocument();
}); });
}); });
test("renders the language picker with native names", async () => {
const Page = (await importLoginPage()).default;
const ui = render(Page);
const select = ui.getByTestId(
"login-language-select",
) as HTMLSelectElement;
const options = Array.from(select.options).map((o) => ({
value: o.value,
text: o.textContent?.trim() ?? "",
}));
expect(options).toEqual([
{ value: "en", text: "English" },
{ value: "ru", text: "Русский" },
]);
expect(select.value).toBe("en");
});
test("switching the language re-renders the form text in place", async () => {
const Page = (await importLoginPage()).default;
const ui = render(Page);
expect(ui.getByTestId("login-email-submit")).toHaveTextContent(
"send code",
);
const select = ui.getByTestId(
"login-language-select",
) as HTMLSelectElement;
await fireEvent.change(select, { target: { value: "ru" } });
await waitFor(() => {
expect(ui.getByTestId("login-email-submit")).toHaveTextContent(
"отправить код",
);
});
expect(i18n.locale).toBe("ru");
});
test("sendEmailCode receives the active locale", async () => {
sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" });
const Page = (await importLoginPage()).default;
const ui = render(Page);
const select = ui.getByTestId(
"login-language-select",
) as HTMLSelectElement;
await fireEvent.change(select, { target: { value: "ru" } });
await fireEvent.input(ui.getByTestId("login-email-input"), {
target: { value: "pilot@example.com" },
});
await fireEvent.click(ui.getByTestId("login-email-submit"));
await waitFor(() => {
expect(sendEmailCodeSpy).toHaveBeenCalledTimes(1);
});
const args = sendEmailCodeSpy.mock.calls[0]!;
expect(args[1]).toBe("pilot@example.com");
expect(args[2]).toEqual({ locale: "ru" });
});
}); });