Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
efe700f
feat(i18n): add machine-assisted French language pack draft
claude Oct 18, 2026
0c42555
test(web): expect the French picker label after choosing Français
claude Oct 18, 2026
10fe054
feat(i18n): translate French entries that were still in English
claude Oct 20, 2026
3a23ccb
test(e2e): add French language e2e tests (spec 019 T035, T037, T062, …
claude Oct 20, 2026
317638b
test(e2e): fix French e2e tests after review
claude Oct 20, 2026
ce88178
docs(specs): tick 019 T104 coverage gates confirmed on CI
claude Oct 20, 2026
fa1cb46
chore: restore modules pointer to 10fbd51
claude Oct 20, 2026
9089eec
test(e2e): close response bodies in the French e2e tests
claude Oct 20, 2026
0c623a7
chore(modules): bump to module French translations; tick 019 T098
claude Oct 20, 2026
ff1ec6f
feat(i18n): credit valgul as French reviewer; tick 019 T096, T097
claude Oct 20, 2026
db31688
test(e2e): run the 0% fixture check and switch language in one tab (s…
claude Oct 20, 2026
8c75ddf
fix(e2e): restore i18n state from defers and move T062/T092 to operat…
claude Oct 20, 2026
bd6d261
test(e2e): address review on the spec 019 French tests
claude Oct 21, 2026
313010b
ci: build the i18n catalogs in the live web e2e job (spec 019 T037)
claude Oct 21, 2026
4c59659
test(e2e): watch for the English flash from the first DOM node (spec …
claude Oct 21, 2026
845722f
Merge master into claude/project-thread-awjqv0
claude Oct 21, 2026
8816dfd
test(e2e): click the Français label, not the hidden radio input (spec…
claude Oct 21, 2026
a52673b
fix(web): keep edge spaces outside i18n messages (spec 019)
claude Oct 21, 2026
5ee32b4
test(e2e): click the fixture language label, not its hidden input (sp…
claude Oct 21, 2026
15c7fc5
fix(web): write the moved edge spaces as JSX text (spec 019)
claude Oct 22, 2026
5d7efa8
test(e2e): anchor the French checks on the servers subtitle (spec 019…
claude Oct 22, 2026
fc86c80
test(e2e): pick Français by its menu item name (spec 019 T037)
claude Oct 22, 2026
33b3a71
test(e2e): check the login heading, not the submit button (spec 019 T…
claude Oct 22, 2026
bc4dc74
test(e2e): retry the notify test-send while the receiver Service warm…
claude Oct 22, 2026
b8c6f19
test(web): set the 0% fixture language per browser in the live test (…
claude Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2029,10 +2029,16 @@ jobs:
- name: install playwright browser
run: |
cd web && npm ci && npx playwright install --with-deps chromium
- name: i18n catalogs for the live dashboard
# The live job serves the dashboard with plain vite, which never builds
# the gitignored language list and catalogs (spec 019 T037).
run: |
cd web && npm run i18n:compile && npm run i18n:manifest
- name: run playwright (live)
env:
GAMEPLANE_E2E_TARGET: live
GAMEPLANE_E2E_CLUSTER: ${{ env.CLUSTER }}
VITE_I18N_FIXTURES: "1"
run: |
cd web && npm run test:e2e:live
- name: dump cluster state on failure (e2e-web-live)
Expand Down
13,232 changes: 13,232 additions & 0 deletions i18n/locales/fr/app.json

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions i18n/locales/fr/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"tag": "fr",
"nativeName": "Français",
"englishName": "French",
"direction": "ltr",
"credits": [
{
"name": "valgul",
"url": "https://github.com/ValgulNecron"
}
]
}
2 changes: 1 addition & 1 deletion modules
16 changes: 8 additions & 8 deletions specs/019-localization-language-packs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ description: "Task list for 019 App Localization & Community Language Packs"

### Tests for User Story 1

- [ ] T035 [P] [US1] Go API E2E `test/e2e/i18n_language_pref_e2e_test.go`: `PUT /api/v1/users/me/language` `fr` → `GET /users/me/preferences` returns `language: "fr"`; `null` clears; unknown tag → 400 with `server.i18n.unknown_language` problem body; register in `test/e2e/buckets.sh` (bucket `api-auth`, respect its login budget)
- [X] T035 [P] [US1] Go API E2E `test/e2e/i18n_language_pref_e2e_test.go`: `PUT /api/v1/users/me/language` `fr` → `GET /users/me/preferences` returns `language: "fr"`; `null` clears; unknown tag → 400 with `server.i18n.unknown_language` problem body; register in `test/e2e/buckets.sh` (bucket `operator` for the login budget; see the comment above `bucket_multicluster`)
- [X] T036 [P] [US1] Go API E2E `test/e2e/i18n_auth_locale_e2e_test.go`: unauthenticated `GET /auth/locale` returns exactly `{"defaultLanguage": …}` and no other keys (rule 3); register in `test/e2e/buckets.sh`
- [ ] T037 [P] [US1] Playwright `web/e2e/specs/live/i18n.spec.ts`: switch to French in personal settings with create-server dialog open and a typed name (kept, no reload, `<html lang="fr">`), reload and second browser context keep French with no English flash, login and share pages offer a picker remembered per browser, 0%-fixture language shows English everywhere (SC-008)
- [X] T037 [P] [US1] Playwright `web/e2e/specs/live/i18n.spec.ts`: switch to French in personal settings with create-server dialog open and a typed name (kept, no reload, `<html lang="fr">`), reload and second browser context keep French with no English flash, login and share pages offer a picker remembered per browser, 0%-fixture language shows English everywhere (SC-008) — same-tab switch per valgul (2026-10-20); kept input covered by I18nProvider.test.tsx; fixture via VITE_I18N_FIXTURES=1 in the live job

### Implementation for User Story 1

Expand Down Expand Up @@ -159,7 +159,7 @@ description: "Task list for 019 App Localization & Community Language Packs"
### Tests for User Story 5

- [X] T061 [P] [US5] Go API E2E `test/e2e/i18n_problem_json_e2e_test.go`: forbidden-namespace and validation errors return byte-identical text/plain bodies without the Accept header and problem bodies with `detail`, `messageId`, `args` with it; failed login returns only `server.auth.invalid_credentials` for both unknown user and wrong password (FR-030); register in `test/e2e/buckets.sh`
- [ ] T062 [P] [US5] Go API E2E `test/e2e/i18n_notify_language_e2e_test.go`: webhook sink with `language: "fr"` and one without; test send to both via `POST /api/v1/admin/notifications/sinks/{name}/test`; first payload has `language: "fr"` and French `title`/`text`, second uses instance default; all pre-existing payload fields unchanged; register in `test/e2e/buckets.sh`
- [X] T062 [P] [US5] Go API E2E `test/e2e/i18n_notify_language_e2e_test.go`: webhook sink with `language: "fr"` and one without; test send to both via `POST /api/v1/admin/notifications/sinks/{name}/test`; first payload has `language: "fr"` and French `title`/`text`, second uses instance default; all pre-existing payload fields unchanged; register in `test/e2e/buckets.sh`
- [X] T063 [P] [US5] envtest `operator/internal/controller/gameserver_envtest_test.go` additions: `status.conditionArgs` set with the condition and cleared when the reason no longer has args (done in the new file `gameserver_condition_args_envtest_test.go`, next to the condition-args code)

### Implementation for User Story 5
Expand Down Expand Up @@ -226,7 +226,7 @@ description: "Task list for 019 App Localization & Community Language Packs"

**Independent Test**: quickstart.md scenario 4.

- [ ] T092 [P] [US7] Go API E2E `test/e2e/i18n_instance_default_e2e_test.go`: `PUT /api/v1/admin/config/i18n` `{defaultLanguage:"fr"}` → `GET /auth/locale` returns `fr`; unlisted tag → 400 `server.i18n.unlisted_language`; non-admin → 403; register in `test/e2e/buckets.sh`
- [X] T092 [P] [US7] Go API E2E `test/e2e/i18n_instance_default_e2e_test.go`: `PUT /api/v1/admin/config/i18n` `{defaultLanguage:"fr"}` → `GET /auth/locale` returns `fr`; unlisted tag → 400 `server.i18n.unlisted_language`; non-admin → 403; register in `test/e2e/buckets.sh`
- [X] T093 [US7] Add config section `i18n` with `validateI18n` (listed languages only) to the validators map in `api/internal/handlers/config.go`; register `server.i18n.unlisted_language`; tests `config_validators_test.go`
- [X] T094 [US7] DESIGN (local machine, Pencil MCP): add a Default language field to admin settings in `design.pen`; export touched nodes to `assets/design-export/`. *Done note (redrawn 2026-10-15): on `WZdnw` the General card and a new Language card sit in a Panel Column; the Language card has the Default language select and its own Save changes button, because the value is saved to the separate config section `i18n`.*
- [X] T095 [US7] Implement the Default language field in `web/src/routes/AdminSettings.tsx` per T094; tests `AdminSettings_sections.test.tsx` *Done note: Language card under General, saved to config section `i18n`; save errors go through `errorTextIntl`, so a 400 `server.i18n.unlisted_language` shows the catalog text.*
Expand All @@ -237,9 +237,9 @@ description: "Task list for 019 App Localization & Community Language Packs"

**Purpose**: French at 100% and the pseudo-language pass. Depends on US4, US5, US6 English references being complete.

- [ ] T096 Create `i18n/locales/fr/meta.json` (`fr`, `Français`, `French`, `ltr`, credits)
- [ ] T097 Translate `i18n/locales/fr/app.json` to 100% (web, server, condition, notify, audit); drafts may be machine-assisted but every entry is approved by a native French reviewer on Weblate or in PR review; `i18n-check` shows fr 100% and listed
- [ ] T098 [module repo] Translate `modules/minecraft-java/locales/fr.json`, `modules/terraria/locales/fr.json`, `modules/valheim/locales/fr.json` to 100%; module-repo PR; root pointer bump after merge
- [X] T096 Create `i18n/locales/fr/meta.json` (`fr`, `Français`, `French`, `ltr`, credits)
- [X] T097 Translate `i18n/locales/fr/app.json` to 100% (web, server, condition, notify, audit); drafts may be machine-assisted but every entry is approved by a native French reviewer on Weblate or in PR review; `i18n-check` shows fr 100% and listed — reviewed and approved by valgul in PR 745 review (2026-10-20)
- [X] T098 [module repo] Translate `modules/minecraft-java/locales/fr.json`, `modules/terraria/locales/fr.json`, `modules/valheim/locales/fr.json` to 100%; module-repo PR; root pointer bump after merge
- [X] T099 Implement pseudo-language `en-XA` generation (accented, ~40% longer, bracketed) behind `VITE_I18N_PSEUDO=1` in the `web/vite.config.ts` build step; never present in release `languages.json` (FR-038)
- [X] T100 Add an `en-XA` project to `web/playwright.config.ts` running the screenshot suite (`web/e2e/screenshots/`), and fix clipping/overlap found (truncation must reveal full text on hover/focus, FR-010); any visual change goes through `design.pen` first (local machine) *Done note: `npm run screenshots:pseudo` runs the screenshot suite as the Playwright `en-XA` project (`VITE_I18N_PSEUDO=1`). The specs keep driving the UI in English; `e2e/screenshots/capture.ts` switches the dashboard to `en-XA` for each capture through `window.gameplaneI18nTest` (installed by `I18nProvider` only when `en-XA` is listed), then soft-fails on text cut off without a `title` (OD-018, OD-019). The CI job "en-XA screenshots" in `visual-diff.yaml` uploads the captures and clipping reports. The first run found only data text already truncated in English (mobile Servers names and addresses, Overview endpoint hosts, module summaries, file names); each now has a `title` with the full text and nothing changes visually, so `design.pen` is unchanged (OD-020). The run is green (122 passed). Screenshots that specs take with `page.screenshot` directly stay English.*

Expand All @@ -250,7 +250,7 @@ description: "Task list for 019 App Localization & Community Language Packs"
- [X] T101 [P] Update `docs/architecture.md`, `docs/agent-architecture.md` (task → doc map entry for i18n) and `docs/module-authoring.md` (module translations section)
- [X] T102 [P] Add a CHANGELOG / release-notes entry describing language selection, French, problem+json negotiation (opt-in) and module translations
- [ ] T103 Run quickstart.md scenarios 1–13 against a `make dev-up` cluster and record results in the PR description
- [ ] T104 Confirm the coverage gates (`i18n` 90, api 80, operator 72, gp-module 80, web thresholds) pass on CI; add tests where they do not (never lower thresholds)
- [X] T104 Confirm the coverage gates (`i18n` 90, api 80, operator 72, gp-module 80, web thresholds) pass on CI; add tests where they do not (never lower thresholds) — confirmed: PR 668 run 38957645731 passed the go (i18n|api|operator|gp-module) threshold gates and the web job; PR 745 web L95.87 S94.37 B89.27 F91.79
- [ ] T105 After merge and with every task complete or withdrawn: `git mv specs/019-localization-language-packs specs/done_019-localization-language-packs` and update in-repo references (Constitution IV, rule 16)

---
Expand Down
10 changes: 9 additions & 1 deletion test/e2e/buckets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ TestAddressPool_ExplicitAddressRequest
TestAddressPool_AddressInUseConflict
TestAddressPool_PoolExhausted
TestAPI_EventStreamAndRoleEdits_FollowCallerPermissions
TestAPI_I18n_LanguagePref
TestAPI_I18n_NotifyLanguage
TestAPI_I18n_InstanceDefault
TestE2E_ResticRepoReady
EOF
}
Expand Down Expand Up @@ -338,7 +341,12 @@ EOF
# e2e-admin login (account removal also uses fake-OIDC sign-ins, which every
# e2e.sh cluster deploys), and TestAPI_I18n_ProblemJSON costs one admin login,
# one viewer login and one failed-login pair, bringing this job to 5 admin
# logins (ceiling ~7). All run against the primary cluster. api-auth,
# logins (ceiling ~7). TestAPI_I18n_LanguagePref (T035),
# TestAPI_I18n_NotifyLanguage (T062) and TestAPI_I18n_InstanceDefault (T092)
# sit in operator for the same login-budget reason: two more admin logins here
# made TestAPI_I18n_ProblemJSON's failed-login pair hit 429. They cost one
# e2e-admin login each, plus one viewer login for T092 (operator: 4 admin
# logins with the EventStream test). All run against the primary cluster. api-auth,
# api-roles, api-mods, api-rbac and api-agent are at their ceilings (see their
# comments); do not add to them.
bucket_multicluster() { cat <<'EOF'
Expand Down
65 changes: 65 additions & 0 deletions test/e2e/fixtures/notify-sink.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Spec 019 T062: a webhook receiver for the notification-sink e2e test
# (TestAPI_I18n_NotifyLanguage). busybox httpd serves one CGI script that
# appends each request's query string and JSON body to /tmp/hooks.log and
# answers 204, so a successful test-send implies the log line was written.
# The test reads the log with `kubectl exec ... cat /tmp/hooks.log`.
apiVersion: v1
kind: ConfigMap
metadata:
name: e2e-notify-sink-cgi
namespace: gameplane-system
data:
hook: |
#!/bin/sh
{ printf '%s\t' "$QUERY_STRING"; head -c "${CONTENT_LENGTH:-0}"; echo; } >> /tmp/hooks.log
printf 'Status: 204 No Content\r\n\r\n'
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: e2e-notify-sink
namespace: gameplane-system
labels: { app.kubernetes.io/name: e2e-notify-sink }
spec:
replicas: 1
selector:
matchLabels: { app.kubernetes.io/name: e2e-notify-sink }
template:
metadata:
labels: { app.kubernetes.io/name: e2e-notify-sink }
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile: { type: RuntimeDefault }
containers:
- name: sink
# Same pinned tag the other e2e fixtures use (oras-push-job.yaml).
image: busybox:1.36
command: ["busybox", "httpd", "-f", "-p", "8080", "-h", "/www"]
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: [ALL] }
ports:
- { name: http, containerPort: 8080, protocol: TCP }
readinessProbe:
tcpSocket: { port: http }
periodSeconds: 2
volumeMounts:
- { name: cgi, mountPath: /www/cgi-bin, readOnly: true }
- { name: tmp, mountPath: /tmp }
volumes:
- name: cgi
configMap: { name: e2e-notify-sink-cgi, defaultMode: 0755 }
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: e2e-notify-sink
namespace: gameplane-system
spec:
selector: { app.kubernetes.io/name: e2e-notify-sink }
ports:
- { name: http, port: 8080, targetPort: http, protocol: TCP }
112 changes: 112 additions & 0 deletions test/e2e/i18n_instance_default_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//go:build e2e

package e2e

import (
"encoding/json"
"net/http"
"strings"
"testing"
)

// TestAPI_I18n_InstanceDefault proves the instance default language (spec 019,
// T092): an admin can set it through PUT /admin/config/i18n, the unauthenticated
// GET /auth/locale reflects it, an unlisted (400) or empty (422) value is rejected without
// writing, and a viewer cannot change it. The default is process-wide, so the
// test holds i18nInstanceDefaultMu for its whole body and restores the prior
// value in a defer before releasing it.
//
// Budget: one e2e-admin login plus one viewer login, in the operator
// bucket for the login budget (see buckets.sh).
func TestAPI_I18n_InstanceDefault(t *testing.T) {
t.Parallel()

envInstance.BootstrapAdmin(t, adminUsername, adminPassword)
admin := envInstance.APIClient(t, adminUsername, adminPassword)
// Close is registered first so it runs last: every cleanup below uses admin.
t.Cleanup(admin.Close)

// Defers, not t.Cleanup, for everything that calls the API: the client's
// port-forward is bound to t.Context(), which is canceled before cleanups
// run, so a request made from a cleanup never reaches the API. Defers run
// last-in first-out, so the lock is released after the default is restored.
i18nInstanceDefaultMu.Lock()
defer i18nInstanceDefaultMu.Unlock()

username, password, id := envInstance.CreateUser(t, admin, "viewer", "e2e-i18n-default")
defer func() {
r, _, _ := admin.Delete("/users/" + id)
if r != nil {
_ = r.Body.Close()
}
}()
viewer := envInstance.APIClient(t, username, password)
t.Cleanup(viewer.Close)

// Restore the prior i18n section, or en when it was absent.
prior, hadPrior := i18nConfigSection(t, admin, "i18n")
defer func() {
body := prior
if !hadPrior {
body = json.RawMessage(`{"defaultLanguage":"en"}`)
}
r, _, _ := admin.Do(http.MethodPut, "/admin/config/i18n", body)
if r != nil {
_ = r.Body.Close()
}
}()

port, stop := envInstance.PortForward(t, "gameplane-system", "svc/gameplane-api", 80)
defer stop()

// The admin sets a listed default; the unauthenticated endpoint reflects it.
resp, body, err := admin.Do(http.MethodPut, "/admin/config/i18n", map[string]string{"defaultLanguage": "fr"})
if resp != nil {
defer func() { _ = resp.Body.Close() }()
}
if err != nil {
t.Fatalf("PUT /admin/config/i18n fr: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("PUT /admin/config/i18n fr: status=%d body=%s", resp.StatusCode, string(body))
}
if got := i18nAuthLocale(t, port); got != "fr" {
t.Fatalf("/auth/locale defaultLanguage=%q after PUT fr, want fr", got)
}

// An unlisted tag is rejected with the registry problem document.
status, ct, raw := rawDo(t, admin, http.MethodPut, "/admin/config/i18n",
map[string]string{"defaultLanguage": "tlh"}, i18nProblemAccept)
if status != http.StatusBadRequest {
t.Fatalf("PUT /admin/config/i18n tlh: status=%d body=%q", status, string(raw))
}
doc := decodeProblemBody(t, "PUT /admin/config/i18n tlh", ct, raw)
requireProblemFields(t, "PUT /admin/config/i18n tlh", doc, http.StatusBadRequest,
"server.i18n.unlisted_language", "language is not available: tlh")
if args, ok := doc["args"].(map[string]any); !ok || args["language"] != "tlh" {
t.Errorf("PUT /admin/config/i18n tlh: args=%v, want language=tlh", doc["args"])
}

// An empty default fails validation as plain text with 422, the status
// every config section returns for a validation error (config.go put).
status, _, raw = rawDo(t, admin, http.MethodPut, "/admin/config/i18n",
map[string]string{"defaultLanguage": ""}, "")
if status != http.StatusUnprocessableEntity || strings.TrimSpace(string(raw)) != "defaultLanguage is required" {
t.Errorf("PUT /admin/config/i18n empty: status=%d body=%q, want 422 \"defaultLanguage is required\"", status, string(raw))
}

// Neither rejected write changed the default.
if got := i18nAuthLocale(t, port); got != "fr" {
t.Fatalf("/auth/locale defaultLanguage=%q after rejected writes, want fr", got)
}

// A viewer cannot change the instance default.
status, _, raw = rawDo(t, viewer, http.MethodPut, "/admin/config/i18n",
map[string]string{"defaultLanguage": "en"}, "")
if status != http.StatusForbidden {
t.Fatalf("viewer PUT /admin/config/i18n: status=%d body=%q, want 403", status, string(raw))
}
if got := i18nAuthLocale(t, port); got != "fr" {
t.Fatalf("/auth/locale defaultLanguage=%q after viewer 403, want fr", got)
}
}
Loading
Loading