190 lines
11 KiB
TypeScript
190 lines
11 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, test } from "@playwright/test";
|
|
import { scanViewTranslationKeys, type ViewTranslationKeyUsage } from "./support/viewI18nKeyScanner";
|
|
|
|
const ACTIVE_LOCALES = ["da", "en", "sv", "de", "no"] as const;
|
|
const GENERATED_LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "generated");
|
|
const REVIEWED_NON_LITERAL_CALLS = new Set([
|
|
"src/views/backoffice/LimitedBackofficeEmployees.vue|t|return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;",
|
|
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue|t|const translated = t(key, params);",
|
|
"src/views/dashboards/superUserDashboard/roles/RolePermissionManager.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/RolePermissionManager.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
|
|
"src/views/dashboards/superUserDashboard/roles/RolePermissionManager.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/RolePermissionManager.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
|
|
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|return t(`replication.status.${status || "unknown"}`);',
|
|
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|te|if (key && te(key)) {",
|
|
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|return t(key, params);",
|
|
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<li v-for="key in composeInstructionKeys" :key="key">{{ t(key) }}</li>',
|
|
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<li v-for="key in redisComposeInstructionKeys" :key="key">{{ t(key) }}</li>',
|
|
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<li v-for="key in minioComposeInstructionKeys" :key="key">{{ t(key) }}</li>',
|
|
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<td>{{ t(`replication.roles.${host.role}`) }}</td>",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
|
|
"src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue|t|const tr = (key, params = {}) => t(`configuration.release_manager.${key}`, params);",
|
|
"src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue|t|const translated = t(fullKey, params);",
|
|
"src/views/dashboards/superUserDashboard/ErrorReports.vue|t|const value = t(key);",
|
|
"src/views/backoffice/LimitedBackofficeEmployees.vue|t|return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
|
|
'src/views/dashboards/userDashboard/wash/components/LaneSelectionSection.vue|$t|:title="option.titleKey ? $t(option.titleKey) : null"',
|
|
'src/views/dashboards/userDashboard/wash/components/LaneSelectionSection.vue|$t|:aria-label="option.titleKey ? $t(option.titleKey) : null"',
|
|
"src/views/dashboards/userDashboard/wash/components/LaneSelectionSection.vue|$t|{{ $t(option.statusLabelKey) }}",
|
|
"src/views/dashboards/userDashboard/wash/components/WashTypeSelector.vue|$t|{{ $t(option.labelKey) }}",
|
|
"src/views/dashboards/userDashboard/wash/components/WashTypeSelector.vue|$t|{{ $t(option.statusLabelKey) }}",
|
|
'src/views/dashboards/userDashboard/wash/MyWashStart.vue|$t|:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"',
|
|
"src/views/dashboards/userDashboard/wash/MyWashStart.vue|$t|{{ $t(confirmActionLabelKey) }}",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
|
|
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
|
|
"src/views/dashboards/superUserDashboard/user/SuperuserUserPageHeader.vue|t|const subtitle = computed(() => t(getSuperuserUserPageSubtitleKey(props.pageKey)));",
|
|
"src/views/dashboards/superUserDashboard/user/UserCustomerRuleManager.vue|t|description: t(rule.descriptionKey),",
|
|
"src/views/dashboards/superUserDashboard/user/UserCustomerRuleManager.vue|t|label: t(rule.labelKey),",
|
|
'src/views/dashboards/superUserDashboard/user/UserOrders.vue|t|? t(definition?.labelKey || "pagination.invoice_per_order")',
|
|
'src/views/dashboards/superUserDashboard/user/UserPricing.vue|t|title: `${isCategory ? getProductCategory(product) : product.name} (${t(isCategory ? "tables.common.category" : "common.product")} ${product.id})`,',
|
|
]);
|
|
|
|
const readGeneratedJson = (fileName: string) => {
|
|
const absolutePath = path.join(GENERATED_LOCALES_DIRECTORY, fileName);
|
|
const content = fs.readFileSync(absolutePath, "utf8").replace(/^\uFEFF/, "");
|
|
return JSON.parse(content) as Record<string, unknown>;
|
|
};
|
|
|
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
|
|
const mergeMessages = (...sources: Array<Record<string, unknown> | undefined>) => {
|
|
const merged: Record<string, unknown> = {};
|
|
|
|
for (const source of sources) {
|
|
if (!source) {
|
|
continue;
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(source)) {
|
|
if (isPlainObject(value) && isPlainObject(merged[key])) {
|
|
merged[key] = mergeMessages(merged[key], value);
|
|
} else {
|
|
merged[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
};
|
|
|
|
const globalMessages = readGeneratedJson("global-v2.json") as {
|
|
shared?: Record<string, unknown>;
|
|
locales?: Partial<Record<(typeof ACTIVE_LOCALES)[number], Record<string, unknown>>>;
|
|
};
|
|
|
|
const readLocale = (locale: (typeof ACTIVE_LOCALES)[number]) =>
|
|
mergeMessages(globalMessages.shared, globalMessages.locales?.[locale], readGeneratedJson(`${locale}-v2.json`));
|
|
|
|
const hasKeyPath = (value: unknown, keyPath: string): boolean => {
|
|
let current: unknown = value;
|
|
|
|
for (const segment of keyPath.split(".")) {
|
|
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
|
return false;
|
|
}
|
|
|
|
const objectValue = current as Record<string, unknown>;
|
|
if (!(segment in objectValue)) {
|
|
return false;
|
|
}
|
|
current = objectValue[segment];
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const formatUsages = (usages: ViewTranslationKeyUsage[]) => {
|
|
const unique = new Set<string>();
|
|
for (const usage of usages) {
|
|
unique.add(`${usage.relativePath}:${usage.line}`);
|
|
}
|
|
return [...unique].sort().join(", ");
|
|
};
|
|
|
|
const formatNonLiteralCalls = (calls: ReturnType<typeof scanViewTranslationKeys>["nonLiteralCalls"]): string => {
|
|
return calls
|
|
.map((call) => `- ${call.relativePath}:${call.line}:${call.column} ${call.functionName}(...) -> ${call.snippet}`)
|
|
.join("\n");
|
|
};
|
|
|
|
const nonLiteralCallSignature = (call: ReturnType<typeof scanViewTranslationKeys>["nonLiteralCalls"][number]) =>
|
|
`${call.relativePath}|${call.functionName}|${call.snippet}`;
|
|
|
|
test.describe("View i18n key coverage", () => {
|
|
test("enforces literal view keys and locale coverage for all active locales", async ({}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== "chromium-desktop",
|
|
"This translation gate runs only on the chromium-desktop project."
|
|
);
|
|
|
|
const scanResult = scanViewTranslationKeys();
|
|
const unreviewedNonLiteralCalls = scanResult.nonLiteralCalls.filter(
|
|
(call) => !REVIEWED_NON_LITERAL_CALLS.has(nonLiteralCallSignature(call))
|
|
);
|
|
|
|
expect(
|
|
unreviewedNonLiteralCalls,
|
|
[
|
|
"Found unreviewed non-literal translation calls in src/views.",
|
|
"Prefer direct string literals in $t(...), t(...), te(...), and $tc(...).",
|
|
"If a dynamic key is unavoidable, keep v2 compatibility keys and add the exact reviewed call signature to this test.",
|
|
formatNonLiteralCalls(unreviewedNonLiteralCalls),
|
|
].join("\n")
|
|
).toEqual([]);
|
|
|
|
const missingByLocale = new Map<string, { key: string; usages: ViewTranslationKeyUsage[] }[]>();
|
|
const sortedKeys = [...scanResult.keyUsageByKey.keys()].sort();
|
|
|
|
for (const locale of ACTIVE_LOCALES) {
|
|
const localeMessages = readLocale(locale);
|
|
const missingEntries: { key: string; usages: ViewTranslationKeyUsage[] }[] = [];
|
|
|
|
for (const key of sortedKeys) {
|
|
if (!hasKeyPath(localeMessages, key)) {
|
|
missingEntries.push({
|
|
key,
|
|
usages: scanResult.keyUsageByKey.get(key) ?? [],
|
|
});
|
|
}
|
|
}
|
|
|
|
if (missingEntries.length > 0) {
|
|
missingByLocale.set(locale, missingEntries);
|
|
}
|
|
}
|
|
|
|
if (missingByLocale.size > 0) {
|
|
const details = [...missingByLocale.entries()]
|
|
.map(([locale, entries]) => {
|
|
const lines = entries.map((entry) => ` - ${entry.key} <- ${formatUsages(entry.usages)}`).join("\n");
|
|
return `${locale} (${entries.length} missing)\n${lines}`;
|
|
})
|
|
.join("\n\n");
|
|
|
|
expect(
|
|
false,
|
|
[
|
|
"Missing translation keys detected for view usage.",
|
|
"Each key below is used in src/views but absent from the locale JSON.",
|
|
details,
|
|
].join("\n\n")
|
|
).toBe(true);
|
|
}
|
|
});
|
|
});
|