diff --git a/end2end/tests/undo.spec.ts b/end2end/tests/undo.spec.ts new file mode 100644 index 0000000..fc5a47a --- /dev/null +++ b/end2end/tests/undo.spec.ts @@ -0,0 +1,106 @@ +import { test, expect, type Page } from "@playwright/test"; + +// Override with BASE_URL (e.g. when the dev server runs on another port). +const BASE = process.env.BASE_URL ?? "http://localhost:3000"; + +// Collects browser console errors and page errors for the duration of a test. +function trackErrors(page: Page): string[] { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") { + errors.push(msg.text()); + } + }); + page.on("pageerror", (err) => { + errors.push(String(err)); + }); + return errors; +} + +// Autosave is debounced at 500 ms; give it time before reloading. +const DEBOUNCE_MS = 700; + +test("undo and redo a campaign name edit", async ({ page }) => { + const errors = trackErrors(page); + await page.goto(`${BASE}/`); + + const original = await page.locator("#campaign-name").inputValue(); + const undo = page.getByRole("button", { name: "Отменить" }); + const redo = page.getByRole("button", { name: "Вернуть" }); + + // Fresh history: both buttons are disabled. + await expect(undo).toBeDisabled(); + await expect(redo).toBeDisabled(); + + // One edit burst; the undo button wakes up immediately (the burst does + // not need to settle first). + const edited = "Имя для отмены"; + await page.locator("#campaign-name").fill(edited); + await expect(undo).toBeEnabled(); + + await undo.click(); + await expect(page.locator("#campaign-name")).toHaveValue(original); + + await redo.click(); + await expect(page.locator("#campaign-name")).toHaveValue(edited); + + // Keyboard: Ctrl+Z outside a text field undoes the redo. + await page.locator(".version").click(); + await page.keyboard.press("Control+z"); + await expect(page.locator("#campaign-name")).toHaveValue(original); + + expect(errors).toEqual([]); +}); + +test("Ctrl+Z inside a text field keeps the browser's native undo", async ({ + page, +}) => { + const errors = trackErrors(page); + await page.goto(`${BASE}/`); + + const input = page.locator("#campaign-name"); + await input.fill("Текст набран в поле"); + await input.press("Control+z"); + + // The app-wide handler must not fire while the field has focus: the + // undo button stays enabled (the pending burst is still undoable). + // The field's own value change is the browser's native undo — it + // owns Ctrl+Z inside text fields by design. + await expect(page.getByRole("button", { name: "Отменить" })).toBeEnabled(); + + expect(errors).toEqual([]); +}); + +test("undone state survives a reload", async ({ page }) => { + const errors = trackErrors(page); + await page.goto(`${BASE}/`); + + const original = await page.locator("#campaign-name").inputValue(); + const edited = "Будет отменено до перезагрузки"; + await page.locator("#campaign-name").fill(edited); + await page.getByRole("button", { name: "Отменить" }).click(); + await expect(page.locator("#campaign-name")).toHaveValue(original); + + // The undo went through the autosave path; wait for the debounce. + await page.waitForTimeout(DEBOUNCE_MS); + await page.reload(); + await expect(page.locator("#campaign-name")).toHaveValue(original); + + expect(errors).toEqual([]); +}); + +test("clear is undoable", async ({ page }) => { + const errors = trackErrors(page); + await page.goto(`${BASE}/`); + + const original = await page.locator("#campaign-name").inputValue(); + await page.getByRole("button", { name: "Очистить" }).click(); + await page.locator(".modal").getByRole("button", { name: "Очистить" }).click(); + await expect(page.locator("#campaign-name")).toHaveValue(""); + + // One undo step restores everything the clear replaced. + await page.getByRole("button", { name: "Отменить" }).click(); + await expect(page.locator("#campaign-name")).toHaveValue(original); + + expect(errors).toEqual([]); +});