docs(i18n): update master plan and add v2 phase 0/1 smoke tests

This commit is contained in:
poyrazavsever
2026-07-20 10:25:40 +03:00
parent 0f2ff48376
commit 9365dfd215
15 changed files with 795 additions and 106 deletions
+6 -5
View File
@@ -74,11 +74,12 @@ function assertMigratedDatabase(dbPath, expectedDefaultLocale) {
],
"Built-in locales must be seeded",
);
assert.deepEqual(
sqlite.prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings").all(),
[{ key: "default", defaultLocale: expectedDefaultLocale, catalogVersion: 1 }],
"Default i18n settings must be seeded",
);
const i18nSettings = sqlite
.prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings")
.get();
assert.equal(i18nSettings.key, "default", "Default i18n settings must be seeded");
assert.equal(i18nSettings.defaultLocale, expectedDefaultLocale, "Default locale must survive backup");
assert.ok(i18nSettings.catalogVersion >= 1, "Catalog version must be positive");
const clientColumns = sqlite.prepare("pragma table_info(clients)").all().map((column) => column.name);
assert.equal(clientColumns.includes("portal_locale"), true, "clients.portal_locale must exist");
+27 -2
View File
@@ -4,7 +4,10 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "../server/db/schema";
import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors";
import { I18nService } from "../server/i18n/service";
import {
ACTIVATION_CRITICAL_KEYS,
I18nService,
} from "../server/i18n/service";
const databasePath = process.argv[2];
assert.ok(databasePath, "Database path is required");
@@ -84,6 +87,7 @@ try {
service.createLocale(owner, { code: "es", name: "Spanish", fallbackLocale: "fr" });
assertDomainError(() => service.updateLocale(owner, "fr", { fallbackLocale: "es" }), "VALIDATION_ERROR");
assertDomainError(() => service.setDefaultLocale(owner, "fr"), "VALIDATION_ERROR");
completeCriticalTranslations(service, "fr");
const activeFrench = service.updateLocale(owner, "fr", { status: "active" });
assert.equal(activeFrench.status, "active");
@@ -91,13 +95,18 @@ try {
assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT");
assertDomainError(() => service.archiveLocale(owner, "en"), "CONFLICT");
service.createLocale(owner, {
const italian = service.createLocale(owner, {
code: "it",
name: "Italian",
nativeName: "Italiano",
status: "active",
fallbackLocale: "en",
});
assert.equal(italian.status, "draft", "Custom locales must always start as draft");
assert.equal(service.getLocaleReadiness(owner, "it").canActivate, false);
completeCriticalTranslations(service, "it");
assert.equal(service.getLocaleReadiness(owner, "it").canActivate, true);
service.updateLocale(owner, "it", { status: "active" });
db.insert(schema.clients).values({
id: "i18n-client",
ownerUserId: owner.authUserId,
@@ -106,6 +115,10 @@ try {
portalLocale: "it",
}).run();
assertDomainError(() => service.archiveLocale(owner, "it"), "CONFLICT");
assert.ok(
service.getNamespaceCompletion(owner, "it").some((item) => item.namespace === "portal"),
);
assert.equal(service.getLocaleUsage(owner, "it").clients, 1);
db.insert(schema.contentTranslations).values({
entityType: "project",
@@ -130,3 +143,15 @@ try {
function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
}
function completeCriticalTranslations(service: I18nService, locale: string) {
for (const fullKey of ACTIVATION_CRITICAL_KEYS) {
const [namespace, ...keyParts] = fullKey.split(".");
service.upsertUiTranslation(owner, {
locale,
namespace,
key: keyParts.join("."),
value: `${locale}:${fullKey}`,
});
}
}
+18 -1
View File
@@ -2,7 +2,10 @@ import assert from "node:assert/strict";
import { getSqliteConnection } from "../server/db/client";
import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors";
import { I18nService } from "../server/i18n/service";
import {
ACTIVATION_CRITICAL_KEYS,
I18nService,
} from "../server/i18n/service";
const owner: DomainActor = {
authUserId: "phase3-owner",
@@ -61,6 +64,8 @@ assert.equal(
true,
);
assertDomainError(() => service.updateLocale(owner, "fr", { status: "active" }), "VALIDATION_ERROR");
completeCriticalTranslations(service, "fr");
service.updateLocale(owner, "fr", { status: "active" });
assert.equal(service.setDefaultLocale(owner, "fr").defaultLocale, "fr");
assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT");
@@ -70,3 +75,15 @@ console.log("I18n phase 3 settings smoke passed.");
function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
}
function completeCriticalTranslations(service: I18nService, locale: string) {
for (const fullKey of ACTIVATION_CRITICAL_KEYS) {
const [namespace, ...keyParts] = fullKey.split(".");
service.upsertUiTranslation(owner, {
locale,
namespace,
key: keyParts.join("."),
value: `${locale}:${fullKey}`,
});
}
}
+14 -4
View File
@@ -4,7 +4,10 @@ import { getSqliteConnection } from "../server/db/client";
import type { DomainActor } from "../server/domain/actor";
import { DomainService } from "../server/services/domain";
import { ContentTranslationService } from "../server/i18n/content";
import { I18nService } from "../server/i18n/service";
import {
ACTIVATION_CRITICAL_KEYS,
I18nService,
} from "../server/i18n/service";
const { db } = getSqliteConnection();
const owner: DomainActor = {
@@ -34,11 +37,18 @@ if (!i18n.listLocales(owner).some((locale) => locale.code === "fr")) {
name: "French",
nativeName: "Français",
fallbackLocale: "en",
status: "active",
});
} else {
i18n.updateLocale(owner, "fr", { status: "active" });
}
for (const fullKey of ACTIVATION_CRITICAL_KEYS) {
const [namespace, ...keyParts] = fullKey.split(".");
i18n.upsertUiTranslation(owner, {
locale: "fr",
namespace,
key: keyParts.join("."),
value: `fr:${fullKey}`,
});
}
i18n.updateLocale(owner, "fr", { status: "active" });
const domain = new DomainService(db, (() => {
let next = 0;
+5 -3
View File
@@ -56,8 +56,10 @@ assert.equal(loginActions.includes("E-posta veya"), false, "login action must no
const inviteActions = fs.readFileSync(path.join(process.cwd(), "app", "invite", "[token]", "actions.ts"), "utf8");
assert.equal(inviteActions.includes("message=${encodeURIComponent"), false, "invite action redirects must use stable codes");
const localeApi = fs.readFileSync(path.join(process.cwd(), "app", "api", "i18n", "locale", "route.ts"), "utf8");
assert.equal(localeApi.includes("Dil kodu"), false, "locale API must not embed Turkish validation messages");
assert.equal(localeApi.includes("messageKey"), true, "locale API must expose messageKey for localized clients");
assert.equal(
fs.existsSync(path.join(process.cwd(), "app", "api", "i18n", "locale", "route.ts")),
false,
"The obsolete locale-cookie API must remain removed",
);
console.log("I18n phase 7 auth/error/a11y smoke passed.");
+243
View File
@@ -0,0 +1,243 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const expectedPages = [
"app/(dashboard)/page.tsx",
"app/(dashboard)/analytics/page.tsx",
"app/(dashboard)/calendar/page.tsx",
"app/(dashboard)/chat/page.tsx",
"app/(dashboard)/clients/page.tsx",
"app/(dashboard)/clients/[id]/page.tsx",
"app/(dashboard)/finance/page.tsx",
"app/(dashboard)/journal/page.tsx",
"app/(dashboard)/projects/page.tsx",
"app/(dashboard)/projects/[id]/page.tsx",
"app/(dashboard)/tasks/page.tsx",
"app/(dashboard)/business/invoices/page.tsx",
"app/(dashboard)/business/proposals/page.tsx",
"app/(dashboard)/business/subscriptions/page.tsx",
"app/login/page.tsx",
"app/register/page.tsx",
"app/forgot-password/page.tsx",
"app/reset-password/page.tsx",
"app/invite/[token]/page.tsx",
"app/portal/page.tsx",
"app/portal/projects/page.tsx",
"app/portal/projects/[id]/page.tsx",
"app/portal/revisions/page.tsx",
"app/portal/tasks/page.tsx",
];
const authPages = [
"app/login/page.tsx",
"app/register/page.tsx",
"app/forgot-password/page.tsx",
"app/reset-password/page.tsx",
];
const settingsPages = [
"app/(dashboard)/settings/general/page.tsx",
"app/(dashboard)/settings/appearance/page.tsx",
"app/(dashboard)/settings/profile/page.tsx",
"app/(dashboard)/settings/security/page.tsx",
"app/(dashboard)/settings/ai/page.tsx",
"app/(dashboard)/settings/language/page.tsx",
"app/(dashboard)/settings/languages/page.tsx",
"app/(dashboard)/settings/languages/new/page.tsx",
"app/(dashboard)/settings/languages/[locale]/page.tsx",
];
const completedSettingsPageFiles = [
"app/(dashboard)/settings/general/page.tsx",
"app/(dashboard)/settings/general/general-settings-form.tsx",
"app/(dashboard)/settings/general/actions.ts",
"app/(dashboard)/settings/appearance/page.tsx",
"app/(dashboard)/settings/appearance/appearance-settings-form.tsx",
"app/(dashboard)/settings/appearance/actions.ts",
"app/(dashboard)/settings/profile/page.tsx",
"app/(dashboard)/settings/profile/profile-settings-form.tsx",
"app/(dashboard)/settings/profile/actions.ts",
"app/(dashboard)/settings/security/page.tsx",
"app/(dashboard)/settings/security/security-settings-form.tsx",
"app/(dashboard)/settings/security/actions.ts",
"app/(dashboard)/settings/ai/page.tsx",
"app/(dashboard)/settings/ai/ai-settings-form.tsx",
"app/(dashboard)/settings/ai/actions.ts",
"app/(dashboard)/settings/language/page.tsx",
"app/(dashboard)/settings/language/language-preference-form.tsx",
"app/(dashboard)/settings/language/actions.ts",
"app/(dashboard)/settings/languages/page.tsx",
"app/(dashboard)/settings/languages/languages-list.tsx",
"app/(dashboard)/settings/languages/actions.ts",
"app/(dashboard)/settings/languages/new/page.tsx",
"app/(dashboard)/settings/languages/new/new-language-form.tsx",
"app/(dashboard)/settings/languages/new/actions.ts",
"app/(dashboard)/settings/languages/[locale]/page.tsx",
"app/(dashboard)/settings/languages/[locale]/language-detail.tsx",
"app/(dashboard)/settings/languages/[locale]/actions.ts",
];
for (const file of [...expectedPages, ...settingsPages]) {
assert.ok(fs.existsSync(path.join(root, file)), `Missing planned page: ${file}`);
}
for (const file of authPages) {
const source = read(file);
assert.ok(source.includes("resolvePublicLocale"), `${file} must use the public locale resolver`);
assert.ok(!source.includes("LocaleSelectForm"), `${file} must not expose a locale selector`);
}
for (const file of completedSettingsPageFiles) {
const source = read(file);
assert.ok(
!/[ÇĞİÖŞÜçğıöşü]/.test(source),
`${file} must not contain hard-coded Turkish user-facing text`,
);
assert.ok(
!source.includes('from "../settings-content"'),
`${file} must not depend on the transitional settings monolith`,
);
}
for (const route of ["general", "appearance", "profile", "security", "ai", "language"]) {
assert.ok(
fs.existsSync(path.join(root, `app/(dashboard)/settings/${route}/actions.ts`)),
`${route} settings must have a route-specific server action boundary`,
);
}
const generalPage = read("app/(dashboard)/settings/general/page.tsx");
assert.ok(
generalPage.includes('locale.status === "active"'),
"General settings must render content tabs for active locales only",
);
const appearancePage = read("app/(dashboard)/settings/appearance/appearance-settings-form.tsx");
for (const marker of ["applyColorMode", "sm:grid-cols-3", "lightLogo", "darkLogo", "favicon"]) {
assert.ok(appearancePage.includes(marker), `Appearance regression marker is missing: ${marker}`);
}
const appearanceActions = read("app/(dashboard)/settings/appearance/actions.ts");
for (const marker of ["deleteSupersededBrandingFiles", "deleteBrandingFilesBestEffort", "requireFreelancerBackend"]) {
assert.ok(appearanceActions.includes(marker), `Appearance action safety marker is missing: ${marker}`);
}
const securityActions = read("app/(dashboard)/settings/security/actions.ts");
for (const marker of ["changePassword", "revokeOtherSessions: true", "errorKey"]) {
assert.ok(securityActions.includes(marker), `Security action marker is missing: ${marker}`);
}
const aiPage = read("app/(dashboard)/settings/ai/page.tsx");
const aiForm = read("app/(dashboard)/settings/ai/ai-settings-form.tsx");
const aiService = read("server/settings/ai.ts");
assert.ok(aiPage.includes("getPublicAiSettings"), "AI settings must use owner-scoped public settings");
assert.ok(!aiPage.includes("apiKey:"), "AI settings page must never send a secret to the client");
for (const marker of ["model", "hasApiKey", "apiKey.masked"]) {
assert.ok(aiForm.includes(marker), `AI settings UX marker is missing: ${marker}`);
}
assert.ok(aiService.includes("model,"), "AI model selection must be persisted");
const languagePage = read("app/(dashboard)/settings/language/page.tsx");
const languageForm = read("app/(dashboard)/settings/language/language-preference-form.tsx");
assert.ok(
languagePage.includes('locale.status === "active"'),
"Language preference must list active locales only",
);
for (const marker of ["nativeName", "defaultLocale", "preferenceNeedsSelection", "router.refresh()"]) {
assert.ok(languageForm.includes(marker), `Language preference marker is missing: ${marker}`);
}
assert.ok(
!fs.existsSync(path.join(root, "app/(dashboard)/settings/settings-content.tsx")),
"The transitional settings monolith must be removed",
);
assert.ok(
!fs.existsSync(path.join(root, "app/(dashboard)/settings/actions.ts")),
"The obsolete shared settings action boundary must be removed",
);
const languagesList = read("app/(dashboard)/settings/languages/languages-list.tsx");
for (const marker of ["completion", "usage", "makeDefault", "archived"]) {
assert.ok(languagesList.includes(marker), `Language list marker is missing: ${marker}`);
}
const languageNewAction = read("app/(dashboard)/settings/languages/new/actions.ts");
for (const marker of ["Intl.getCanonicalLocales", "SUPPORTED_BCP47_PATTERN", "createLocale"]) {
assert.ok(languageNewAction.includes(marker), `New language marker is missing: ${marker}`);
}
const languageDetail = read("app/(dashboard)/settings/languages/[locale]/language-detail.tsx");
for (const marker of ["namespaceCompletion", "readiness", "usage", "DestructiveConfirmation"]) {
assert.ok(languageDetail.includes(marker), `Language detail marker is missing: ${marker}`);
}
const i18nService = read("server/i18n/service.ts");
assert.ok(
i18nService.includes('status: "draft"'),
"Custom languages must always be created as draft",
);
for (const marker of ["assertLocaleCanBeActivated", "ACTIVATION_CRITICAL_KEYS", "getLocaleReadiness"]) {
assert.ok(i18nService.includes(marker), `Language lifecycle service marker is missing: ${marker}`);
}
const resolver = read("server/i18n/resolver.ts");
for (const exportName of [
"resolvePublicLocale",
"resolveInvitationLocale",
"resolveFreelancerLocale",
"resolvePortalLocale",
]) {
assert.ok(resolver.includes(exportName), `Resolver export is missing: ${exportName}`);
}
assert.ok(!resolver.includes("LOCALE_COOKIE"), "Server locale authority must not depend on the locale cookie");
assert.ok(
!fs.existsSync(path.join(root, "app/api/i18n/locale/route.ts")),
"The obsolete locale-cookie mutation route must not exist",
);
assert.ok(
!fs.existsSync(path.join(root, "components/i18n/locale-select-form.tsx")),
"The obsolete public locale selector must not exist",
);
const settingsNavigation = read("app/(dashboard)/settings/settings-navigation.tsx");
assert.ok(settingsNavigation.includes("md:sticky"), "Settings navigation must be sticky on desktop");
assert.ok(settingsNavigation.includes("usePathname"), "Settings navigation must be route-aware");
for (const boundary of ["loading.tsx", "error.tsx", "not-found.tsx"]) {
assert.ok(
fs.existsSync(path.join(root, "app/(dashboard)/settings", boundary)),
`Settings boundary is missing: ${boundary}`,
);
}
const invitationService = read("server/auth/invitations.ts");
const clientLocaleMutation = invitationService.slice(
invitationService.indexOf("export function setClientPortalLocale"),
);
assert.ok(
!clientLocaleMutation.includes("tx.insert(userPreferences)"),
"Updating the admin-assigned client locale must preserve the client's personal preference",
);
const userFacingFiles = listFiles(["app", "components", "config"])
.filter((file) => /\.(ts|tsx)$/.test(file));
const hardCodedCandidates = userFacingFiles
.map((file) => ({
file,
count: (read(file).match(/[ÇĞİÖŞÜçğıöşü]/g) ?? []).length,
}))
.filter((entry) => entry.count > 0)
.sort((left, right) => right.count - left.count);
console.log(`I18n V2 page audit passed: ${expectedPages.length + settingsPages.length} page routes accounted for.`);
console.log(`Baseline hard-coded Turkish-character candidates: ${hardCodedCandidates.length} files.`);
for (const entry of hardCodedCandidates.slice(0, 12)) {
console.log(` ${entry.count.toString().padStart(4)} ${entry.file}`);
}
function read(file) {
return fs.readFileSync(path.join(root, file), "utf8");
}
function listFiles(roots) {
const result = [];
for (const rootName of roots) {
walk(path.join(root, rootName), rootName);
}
return result;
function walk(absolute, relative) {
for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
const entryAbsolute = path.join(absolute, entry.name);
const entryRelative = path.join(relative, entry.name);
if (entry.isDirectory()) walk(entryAbsolute, entryRelative);
else result.push(entryRelative);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const outputRoot = path.join(root, ".next", "i18n-v2-phase1-smoke-dist");
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-v2-phase1-smoke.json"], {
cwd: root,
stdio: "inherit",
});
const packageFile = path.join(outputRoot, "package.json");
fs.writeFileSync(packageFile, '{"type":"commonjs"}\n');
execFileSync(
process.execPath,
[path.join(outputRoot, "scripts", "i18n-v2-phase1-smoke.js")],
{ cwd: root, stdio: "inherit" },
);
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { resolveLocalePolicy } from "../lib/i18n/locale-resolution";
const locales = [
{ code: "tr", status: "active", textDirection: "ltr" },
{ code: "en", status: "active", textDirection: "ltr" },
{ code: "fr", status: "active", textDirection: "ltr" },
{ code: "de", status: "archived", textDirection: "ltr" },
{ code: "ar", status: "active", textDirection: "rtl" },
] as const;
const publicLocale = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "en",
candidates: [],
});
assert.equal(publicLocale.locale, "en");
assert.equal(publicLocale.source, "instance");
const freelancerLocale = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [{ locale: "en", source: "user" }],
});
assert.equal(freelancerLocale.locale, "en");
assert.equal(freelancerLocale.source, "user");
const portalPreference = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [
{ locale: "en", source: "user" },
{ locale: "fr", source: "client" },
],
});
assert.equal(portalPreference.locale, "en", "Personal preference must win over the client default");
const portalDefault = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [
{ locale: null, source: "user" },
{ locale: "fr", source: "client" },
],
});
assert.equal(portalDefault.locale, "fr");
assert.equal(portalDefault.source, "client");
const invitationLocale = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [{ locale: "fr", source: "invitation" }],
});
assert.equal(invitationLocale.locale, "fr");
assert.equal(invitationLocale.source, "invitation");
const archivedPreference = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [{ locale: "de", source: "user" }],
});
assert.equal(archivedPreference.locale, "tr");
assert.equal(archivedPreference.source, "instance");
const invalidDefault = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "de",
candidates: [],
});
assert.equal(invalidDefault.locale, "tr");
assert.equal(invalidDefault.source, "fallback");
const rtlLocale = resolveLocalePolicy({
activeLocales: locales,
defaultLocale: "tr",
candidates: [{ locale: "ar", source: "user" }],
});
assert.equal(rtlLocale.direction, "rtl");
console.log("I18n V2 phase 1 locale policy smoke passed.");