Files
pleno-vue/tests/e2e/i18n.views.spec.ts
Jeppe B 917c10c1d3 Fix mobile Stripe reader recovery action (#241)
## Summary
- keep the mobile Stripe terminal menu in normal document flow so it
cannot cover the reader recovery action
- preserve the existing desktop dropdown overlay behavior
- replace pre-existing dynamic subuser-access translations with direct
literal calls
- explicitly review Bird's bounded health-state translation signatures
in the full view-i18n gate

## Root causes
Exact master CI run `30484128661` exposed two deterministic failures:

1. All three attempts of `reader unavailable state is recoverable and
non-fatal` timed out because the open terminal dropdown intercepted
pointer events on `pos-stripe-no-readers`.
2. The full Chromium desktop/customer view-i18n gate rejected two
bounded Bird health-state signatures plus three pre-existing dynamic
subuser-access calls. The PR matrix did not run this full scanner.

## Verification
- exact Chromium mobile failing scenario: 1/1 passed
- Chromium mobile repeat stress: 5/5 passed
- exact full view-i18n scanner: 1/1 passed
- Bird configuration units: 5/5 passed
- Firefox mobile scenario: skipped by the existing project/browser gate
- ESLint: passed
- test Prettier check: passed
- `git diff --check`: passed
- Vite production build: passed
2026-08-02 09:42:47 +02:00

195 lines
12 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/dashboards/superUserDashboard/configuration/ConfigurationBird.vue|$t|:subtitle="$t(`configuration.bird.state.${healthState}`)"',
"src/views/dashboards/superUserDashboard/configuration/ConfigurationBird.vue|$t|{{ $t(`configuration.bird.state.${healthState}`) }}",
"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/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.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 ({
browserName: _browserName,
}, 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);
}
});
});