diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5e373faf..6776fbf0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -123,6 +123,17 @@ jobs:
# this step, so the *.pg.test.ts files probe the unreachable default
# and self-skip — same as today. They get real coverage in the
# dedicated serial step below.
+ #
+ # `test` pins --test-concurrency=4 (issue #605). Node's default is
+ # derived from the core count, so a 16-core dev machine ran far more
+ # files at once than CI ever does — and the suite spins up many
+ # short-lived Express servers on localhost. Three consecutive full runs
+ # each failed a DIFFERENT test (runtimeSecretsRoute,
+ # conductorTemplateRoutes, inboundWebhookRoutes), two with a
+ # connection-level `fetch failed`; every one passed in isolation. That
+ # is contention, not logic. Pinning makes the number the same
+ # everywhere, so a red run means a real failure instead of training
+ # everyone to re-run — the habit that let #549 break main.
- name: Test (node --test via tsx)
run: npm run test
diff --git a/middleware/package.json b/middleware/package.json
index 3bedb686..c2783db4 100644
--- a/middleware/package.json
+++ b/middleware/package.json
@@ -41,7 +41,7 @@
"eval:golden": "node --import tsx test/golden/goldenSet.eval.ts",
"setup:tigris-lifecycle": "tsx scripts/setup-tigris-lifecycle.ts",
"pretest": "node scripts/check-node-version.mjs",
- "test": "node --import tsx --test --test-timeout=120000 --test-reporter=spec 'test/**/*.test.ts'",
+ "test": "node --import tsx --test --test-timeout=120000 --test-concurrency=4 --test-reporter=spec 'test/**/*.test.ts'",
"test:pg": "node --import tsx --test --test-timeout=120000 --test-concurrency=1 --test-reporter=spec 'test/**/*.pg.test.ts'"
},
"engines": {
diff --git a/web-ui/CLAUDE.md b/web-ui/CLAUDE.md
index d0089ed0..90400a72 100644
--- a/web-ui/CLAUDE.md
+++ b/web-ui/CLAUDE.md
@@ -13,9 +13,12 @@ When you add or change any user-facing string:
1. Add the key to `messages/en.json` (English is the source of truth).
2. Mirror the **same key** in `messages/de.json` with the German translation.
3. Use it in components via `next-intl`: `const t = useTranslations('.')`.
-4. Run `npm run i18n:check` — the parity test fails on missing/extra/empty keys,
- forbidden HTML (`script/iframe/object/embed/link`), or mismatched ICU/tag
- placeholders across locales.
+4. Run `npm run i18n:check` — `scripts/i18n-validate.mjs` fails on missing/extra
+ keys, empty or non-string values, and forbidden HTML
+ (`script/iframe/object/embed/link`).
+5. Run `npm test` — the parity suite (`app/_lib/i18n-parity.test.ts`) additionally
+ fails on **mismatched ICU arguments and rich-text tags** across locales. This
+ check is not part of `i18n:check`; it runs under vitest, and CI runs it.
Do **not** hardcode user-facing strings in components. See `messages/README.md`
for the full key-naming convention, ICU placeholders, and `t.rich` usage.
diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json
index 065ae5c4..d7c0413d 100644
--- a/web-ui/package-lock.json
+++ b/web-ui/package-lock.json
@@ -31,6 +31,7 @@
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
+ "@formatjs/icu-messageformat-parser": "^3.5.16",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
diff --git a/web-ui/package.json b/web-ui/package.json
index 165d4362..89ec58c2 100644
--- a/web-ui/package.json
+++ b/web-ui/package.json
@@ -49,6 +49,7 @@
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
+ "@formatjs/icu-messageformat-parser": "^3.5.16",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",