docs(i18n): update master plan and add v2 phase 0/1 smoke tests
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user