/** * i18n E2E Tests * * Tests the internationalization features in the admin UI: * - Locale column in content list * - Locale filter in content list * - Translations sidebar in content editor * - Creating translations via the admin UI * - Navigating between translations * - Slug correctness (no locale suffix accumulation) * * The e2e fixture has i18n configured with locales: en, fr, es * and defaultLocale: en. * * Seed data: * - posts: "First Post" (en, published), "Second Post" (en, published), "Draft Post" (en, draft) * - pages: "About" (en, published), "Contact" (en, draft) */ import { test, expect } from "../fixtures"; const CONTENT_EDIT_URL_PATTERN = /\/content\/posts\/[A-Z0-9]+$/; test.describe("i18n", () => { test.beforeEach(async ({ admin }) => { await admin.devBypassAuth(); }); test.describe("Content List", () => { test("shows locale column when i18n is configured", async ({ admin }) => { await admin.goToContent("posts"); await admin.waitForLoading(); // The table should have a "Locale" column header const localeHeader = admin.page.locator("th", { hasText: "Locale" }); await expect(localeHeader).toBeVisible(); }); test("displays locale badges for each content item", async ({ admin }) => { await admin.goToContent("posts"); await admin.waitForLoading(); // All seeded posts are English — should see EN badges const locales = await admin.getLocaleColumnValues(); expect(locales.length).toBeGreaterThan(0); // All seeded content is "en" for (const locale of locales) { expect(locale.trim().toLowerCase()).toBe("en"); } }); test("has a locale filter switcher", async ({ admin }) => { await admin.goToContent("posts"); await admin.waitForLoading(); // Should have a select element for locale filtering const select = admin.page.locator("select").first(); await expect(select).toBeVisible(); // Should show available locale options const options = select.locator("option"); const optionTexts = await options.allTextContents(); // Expect EN, FR, ES options (may also have "All locales") expect(optionTexts.some((t) => t.includes("EN"))).toBe(true); expect(optionTexts.some((t) => t.includes("FR"))).toBe(true); expect(optionTexts.some((t) => t.includes("ES"))).toBe(true); }); }); test.describe("Content Editor", () => { test("shows translations sidebar for existing content", async ({ admin }) => { await admin.goToContent("posts"); await admin.waitForLoading(); // Click on any post to edit (use first link in table body) await admin.page.locator("table tbody tr a").first().click(); await admin.waitForLoading(); // Should see the Translations sidebar heading const translationsHeading = admin.page.locator("h3", { hasText: "Translations", }); await expect(translationsHeading).toBeVisible(); }); test("shows all configured locales in translations sidebar", async ({ admin }) => { await admin.goToContent("posts"); await admin.waitForLoading(); await admin.page.locator("table tbody tr a").first().click(); await admin.waitForLoading(); // Should show en, fr, es in the sidebar const locales = await admin.getTranslationSidebarLocales(); const normalized = locales.map((l) => l.trim().toLowerCase()); expect(normalized).toContain("en"); expect(normalized).toContain("fr"); expect(normalized).toContain("es"); }); test("marks current locale in translations sidebar", async ({ admin }) => { await admin.goToContent("posts"); await admin.waitForLoading(); await admin.page.locator("table tbody tr a").first().click(); await admin.waitForLoading(); // The "current" marker should appear next to EN const currentMarker = admin.page.locator("span.text-kumo-brand", { hasText: "current", }); await expect(currentMarker).toBeVisible(); }); test("shows Translate buttons for missing locales", async ({ admin }) => { await admin.goToContent("posts"); await admin.waitForLoading(); await admin.page.locator("table tbody tr a").first().click(); await admin.waitForLoading(); // FR and ES should have "Translate" buttons since no translations exist yet expect(await admin.hasTranslateButton("fr")).toBe(true); expect(await admin.hasTranslateButton("es")).toBe(true); }); test("does not show translations sidebar for new content", async ({ admin }) => { await admin.goToNewContent("posts"); await admin.waitForLoading(); // The translations sidebar should NOT be visible for unsaved content const translationsHeading = admin.page.locator("h3", { hasText: "Translations", }); await expect(translationsHeading).not.toBeVisible(); }); }); test.describe("Translation Flow", () => { test("creates a translation and navigates to it", async ({ admin }) => { // Create a fresh post so we have a clean translation group await admin.goToNewContent("posts"); await admin.waitForLoading(); const postTitle = `i18n Test Post ${Date.now()}`; await admin.fillField("title", postTitle); await admin.clickSave(); // Wait for redirect to edit page await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, { timeout: 10000, }); await admin.waitForLoading(); // Capture the original post URL const originalUrl = admin.page.url(); // Should see Translate buttons for FR and ES expect(await admin.hasTranslateButton("fr")).toBe(true); // Click "Translate" for FR — wait for URL to change (SPA navigation) await admin.clickTranslate("fr"); await admin.page.waitForURL( (url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== originalUrl, { timeout: 15000 }, ); await admin.waitForLoading(); // The title should be pre-filled from the original await expect(admin.page.locator("#field-title")).toHaveValue(postTitle); // The slug should be the same as the original (no locale suffix) const slug = await admin.page.getByLabel("Slug").inputValue(); expect(slug).not.toContain("-fr"); expect(slug).not.toContain("-en"); }); test("shows Edit link for existing translations", async ({ admin }) => { // Create a post and its FR translation await admin.goToNewContent("posts"); await admin.waitForLoading(); const postTitle = `Translation Edit Test ${Date.now()}`; await admin.fillField("title", postTitle); await admin.clickSave(); await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, { timeout: 10000, }); await admin.waitForLoading(); const originalUrl = admin.page.url(); // Create FR translation and wait for navigation await admin.clickTranslate("fr"); await admin.page.waitForURL( (url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== originalUrl, { timeout: 15000 }, ); await admin.waitForLoading(); // Now on the FR translation — EN should show "Edit" link, not "Translate" expect(await admin.hasEditTranslationLink("en")).toBe(true); // ES should still show "Translate" expect(await admin.hasTranslateButton("es")).toBe(true); }); test("can navigate between translations via Edit links", async ({ admin }) => { // Create a post and FR translation await admin.goToNewContent("posts"); await admin.waitForLoading(); const postTitle = `Navigation Test ${Date.now()}`; await admin.fillField("title", postTitle); await admin.clickSave(); await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, { timeout: 10000, }); await admin.waitForLoading(); const originalUrl = admin.page.url(); // Create FR translation and wait for navigation await admin.clickTranslate("fr"); await admin.page.waitForURL( (url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== originalUrl, { timeout: 15000 }, ); await admin.waitForLoading(); const frUrl = admin.page.url(); // Navigate back to EN via Edit link await admin.clickEditTranslation("en"); await admin.page.waitForURL( (url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== frUrl, { timeout: 15000 }, ); await admin.waitForLoading(); // Should be back on the original post await expect(admin.page).toHaveURL(originalUrl); await expect(admin.page.locator("#field-title")).toHaveValue(postTitle); }); test("creating multiple translations does not accumulate locale suffixes in slugs", async ({ admin, }) => { // This is the regression test for the slug accumulation bug: // old code: slug = rawItem.slug + "-" + locale // Each translate would append more suffixes: post-fr-en-fr-en... await admin.goToNewContent("posts"); await admin.waitForLoading(); const postTitle = `Slug Accumulation Test ${Date.now()}`; await admin.fillField("title", postTitle); await admin.clickSave(); await expect(admin.page).toHaveURL(CONTENT_EDIT_URL_PATTERN, { timeout: 10000, }); await admin.waitForLoading(); const originalUrl = admin.page.url(); const originalSlug = await admin.page.getByLabel("Slug").inputValue(); expect(originalSlug).toBeTruthy(); // Create FR translation and wait for navigation await admin.clickTranslate("fr"); await admin.page.waitForURL( (url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== originalUrl, { timeout: 15000 }, ); await admin.waitForLoading(); // FR slug should be the same as original (UNIQUE(slug, locale) allows this) const frSlug = await admin.page.getByLabel("Slug").inputValue(); expect(frSlug).toBe(originalSlug); const frUrl = admin.page.url(); // Navigate back to EN await admin.clickEditTranslation("en"); await admin.page.waitForURL( (url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== frUrl, { timeout: 15000 }, ); await admin.waitForLoading(); const enUrl = admin.page.url(); // Create ES translation from EN await admin.clickTranslate("es"); await admin.page.waitForURL( (url) => CONTENT_EDIT_URL_PATTERN.test(url.pathname) && url.href !== enUrl, { timeout: 15000 }, ); await admin.waitForLoading(); // ES slug should also be the same — no accumulation const esSlug = await admin.page.getByLabel("Slug").inputValue(); expect(esSlug).toBe(originalSlug); }); }); });