import { test, expect, Page, Locator, Dialog } from "@playwright/test"; /** * Playwright spec for https://hizbu.com/regex/ * Written against the page source (CodeMirror 6 x2, tabs, #fullscreen checkbox). * * Requires Playwright >= 1.45 (for the "ControlOrMeta" modifier) and network access * to esm.sh (the page loads CodeMirror from there). * * Reading cursor/selection/doc state uses CodeMirror's EditorView, found through the * DOM node (`cmView` / `cmTile` property). If a CodeMirror upgrade ever moves that * property, only the `__cmView` init script below needs updating. */ const URL = "http://localhost:4000/regex/"; const KEY = { patterns: "regex_patterns", docs: "regex_documents", titles: "regex_tab_titles", active: "regex_active_tab", }; const DEFAULT_TITLES = ["Emails & IDs", "Code Snippet", "Server Logs"]; const NEW_TAB_TEXT = "// Type or paste text here...\n"; // ---------- locators ---------- const topEditor = (p: Page) => p.locator("#regex-editor .cm-content"); const mainEditor = (p: Page) => p.locator("#cm6-editor .cm-content"); const tabs = (p: Page) => p.locator('#tab-bar [role="tab"]'); const tabLabel = (p: Page, i: number) => tabs(p).nth(i).locator('span[title="Double-click to rename"]'); const tabClose = (p: Page, i: number) => tabs(p).nth(i).locator(".tab-close"); const addTabBtn = (p: Page) => p.locator("#add-tab-btn"); const fullscreen = (p: Page) => p.locator("#fullscreen"); const matchTexts = (p: Page, n: number) => p.locator(`#cm6-editor .cm-regex-match-${n}`).allTextContents(); // ---------- helpers ---------- const docText = (l: Locator): Promise => l.evaluate((el: any) => { try { return (window as any).__cmView(el).state.doc.toString(); } catch { return [...el.querySelectorAll(".cm-line")] .map((x: any) => x.textContent) .join("\n"); } }); const selection = (l: Locator): Promise<{ anchor: number; head: number }> => l.evaluate((el: any) => { const s = (window as any).__cmView(el).state.selection.main; return { anchor: s.anchor, head: s.head }; }); /** Focus the editor WITHOUT changing its selection (unlike click). */ const focusEditor = (l: Locator) => l.evaluate((el: any) => (window as any).__cmView(el).focus()); const storage = (p: Page, key: string) => p.evaluate((k) => localStorage.getItem(k), key); const storageJson = async (p: Page, key: string) => JSON.parse((await storage(p, key)) ?? "null"); async function waitReady(p: Page) { await expect(p.locator("#loading")).toHaveCount(0, { timeout: 30_000 }); await expect(topEditor(p)).toBeVisible(); await expect(mainEditor(p)).toBeVisible(); } async function reload(p: Page) { await p.reload(); await waitReady(p); } /** Replace the whole content of a CodeMirror editor by typing it. */ async function setDoc(p: Page, editor: Locator, lines: string[]) { await editor.click(); await p.keyboard.press("ControlOrMeta+a"); await p.keyboard.press("Backspace"); for (let i = 0; i < lines.length; i++) { if (i > 0) await p.keyboard.press("Enter"); if (lines[i]) await p.keyboard.type(lines[i]); } } /** Click a tab by its label (switches tab). */ const switchTo = (p: Page, i: number) => tabLabel(p, i).click(); async function redo(p: Page) { // CodeMirror historyKeymap: Mod-y = redo (Win/Linux), Mod-Shift-z = redo (Mac). // Ctrl+Shift+Z is avoided on purpose: on Linux Chromium it was observed to act as undo. const isMac = await p.evaluate(() => /Mac/i.test(navigator.platform)); await p.keyboard.press(isMac ? "Meta+Shift+z" : "Control+y"); } /** Close a tab; asserts the native confirm() text, then accepts or dismisses it. */ async function closeTab( p: Page, index: number, accept: boolean, expectedName?: string, ) { // window.confirm() blocks the page, so click() cannot resolve until the dialog is answered. // The handler therefore must be registered BEFORE the click and answer immediately. const seen: { type: string; message: string }[] = []; const handler = async (d: Dialog) => { seen.push({ type: d.type(), message: d.message() }); await (accept ? d.accept() : d.dismiss()); }; p.on("dialog", handler); try { await tabClose(p, index).click(); } finally { p.off("dialog", handler); } expect(seen, "expected exactly one confirm() dialog").toHaveLength(1); expect(seen[0].type).toBe("confirm"); if (expectedName) expect(seen[0].message).toBe( `Are you sure you want to close "${expectedName}"?`, ); } test.beforeEach(async ({ page }) => { test.setTimeout(60_000); // Exposes CodeMirror's EditorView for a .cm-content node (cmView in older, cmTile in newer builds) await page.addInitScript(() => { (window as any).__cmView = (el: any) => { const t = el.cmView ?? el.cmTile; const v = t?.view ?? t?.root?.view ?? t?.rootView?.view; if (!v) throw new Error( "Could not locate CodeMirror EditorView on .cm-content", ); return v; }; }); await page.goto(URL); await page.evaluate(() => localStorage.clear()); await reload(page); }); // ===================================================================== // 0. initial state // ===================================================================== test.describe("initial state", () => { test("loads default tabs, regexes and highlights", async ({ page }) => { await expect(tabs(page)).toHaveCount(3); await expect(tabs(page).nth(0)).toHaveClass(/active/); for (let i = 0; i < 3; i++) await expect(tabLabel(page, i)).toHaveText(DEFAULT_TITLES[i]); await expect(topEditor(page)).toContainText("[0-9]{4}-[0-9]{2}-[0-9]{2}"); await expect .poll(() => matchTexts(page, 0)) .toContain("support@example.com"); await expect.poll(() => matchTexts(page, 1)).toContain("2026-10-17"); }); }); // ===================================================================== // 1. every regex line is reflected in the lower editor // ===================================================================== test.describe("regex lines -> lower editor highlights", () => { test("each line is reflected (highlight class N = line index % 5) as it is typed", async ({ page, }) => { await setDoc(page, mainEditor(page), ["abc 123 DEF 456"]); await setDoc(page, topEditor(page), []); // clear default regexes, leaves top editor focused const lines = [ { text: "/[a-z]+/", expected: ["abc"] }, { text: "/[0-9]+/", expected: ["123", "456"] }, { text: "/[A-Z]+/", expected: ["DEF"] }, { text: "/zzz/", expected: [] }, { text: "/ /", expected: [" ", " ", " "] }, ]; for (let i = 0; i < lines.length; i++) { if (i > 0) await page.keyboard.press("Enter"); await page.keyboard.type(lines[i].text); // lower editor shows this line's matches with its colour class await expect.poll(() => matchTexts(page, i)).toEqual(lines[i].expected); // top editor line fringe uses the same colour index await expect(page.locator("#regex-editor .cm-line").nth(i)).toHaveClass( new RegExp(`cm-regex-line-${i % 5}`), ); // all earlier lines are still reflected for (let j = 0; j < i; j++) expect(await matchTexts(page, j)).toEqual(lines[j].expected); } await expect .poll(() => storage(page, KEY.patterns)) .toBe(lines.map((l) => l.text).join("\n")); }); test("colour index wraps after 5 lines", async ({ page }) => { await setDoc(page, mainEditor(page), ["w0 w1 w2 w3 w4 w5"]); await setDoc( page, topEditor(page), [0, 1, 2, 3, 4, 5].map((i) => `/w${i}/`), ); await expect.poll(() => matchTexts(page, 0)).toEqual(["w0", "w5"]); for (const n of [1, 2, 3, 4]) expect(await matchTexts(page, n)).toEqual([`w${n}`]); await expect(page.locator("#regex-editor .cm-line").nth(5)).toHaveClass( /cm-regex-line-0/, ); }); test("blank lines are skipped but still consume a line index", async ({ page, }) => { await setDoc(page, mainEditor(page), ["abc 123"]); await setDoc(page, topEditor(page), ["/abc/", "", "/123/"]); await expect.poll(() => matchTexts(page, 0)).toEqual(["abc"]); expect(await matchTexts(page, 1)).toEqual([]); expect(await matchTexts(page, 2)).toEqual(["123"]); }); test("editing a line updates highlights; deleting all lines removes them", async ({ page, }) => { await setDoc(page, mainEditor(page), ["cat dog bird"]); await setDoc(page, topEditor(page), ["/cat/"]); await expect.poll(() => matchTexts(page, 0)).toEqual(["cat"]); // replace line 0 await page.keyboard.press("ControlOrMeta+Home"); await page.keyboard.press("Shift+End"); await page.keyboard.type("/dog/"); await expect.poll(() => matchTexts(page, 0)).toEqual(["dog"]); // delete everything await page.keyboard.press("ControlOrMeta+a"); await page.keyboard.press("Backspace"); await expect( page.locator('#cm6-editor [class*="cm-regex-match-"]'), ).toHaveCount(0); }); test("editing the lower editor text refreshes highlights", async ({ page, }) => { await setDoc(page, topEditor(page), ["/foo/"]); await setDoc(page, mainEditor(page), ["nothing here"]); await expect(page.locator("#cm6-editor .cm-regex-match-0")).toHaveCount(0); await page.keyboard.type(" foo foo"); await expect.poll(() => matchTexts(page, 0)).toEqual(["foo", "foo"]); }); test("flags: g default, explicit flags honoured, no-g matches only once", async ({ page, }) => { await setDoc(page, mainEditor(page), ["abc ABC abc"]); await setDoc(page, topEditor(page), ["/abc/gi"]); await expect.poll(() => matchTexts(page, 0)).toEqual(["abc", "ABC", "abc"]); await setDoc(page, topEditor(page), ["/abc/i"]); // no g -> first match only await expect.poll(() => matchTexts(page, 0)).toEqual(["abc"]); await setDoc(page, topEditor(page), ["abc"]); // no slashes -> raw pattern, g flag await expect.poll(() => matchTexts(page, 0)).toEqual(["abc", "abc"]); }); test("invalid regex line is ignored without breaking other lines", async ({ page, }) => { const errors: string[] = []; page.on("pageerror", (e) => errors.push(e.message)); await setDoc(page, mainEditor(page), ["abc 123"]); await setDoc(page, topEditor(page), ["/(/", "/123/"]); await expect.poll(() => matchTexts(page, 1)).toEqual(["123"]); expect(await matchTexts(page, 0)).toEqual([]); expect(errors).toEqual([]); }); test("top editor gets one fringe-coloured line per regex line", async ({ page, }) => { await setDoc(page, topEditor(page), ["a", "b", "c", "d", "e", "f"]); const lines = page.locator("#regex-editor .cm-line"); await expect(lines).toHaveCount(6); for (let i = 0; i < 6; i++) await expect(lines.nth(i)).toHaveClass( new RegExp(`cm-regex-line-${i % 5}`), ); }); }); // ===================================================================== // 2. localStorage // ===================================================================== test.describe("localStorage", () => { test("nothing is written until the first change, then all keys exist", async ({ page, }) => { expect(await storage(page, KEY.docs)).toBeNull(); await setDoc(page, topEditor(page), ["/x/"]); await expect.poll(() => storage(page, KEY.patterns)).toBe("/x/"); expect(await storageJson(page, KEY.titles)).toEqual(DEFAULT_TITLES); expect(await storage(page, KEY.active)).toBe("0"); expect((await storageJson(page, KEY.docs)).length).toBe(3); }); test("regex editor text is stored and restored", async ({ page }) => { await setDoc(page, topEditor(page), ["/one/", "/two/g"]); await expect.poll(() => storage(page, KEY.patterns)).toBe("/one/\n/two/g"); await reload(page); expect(await docText(topEditor(page))).toBe("/one/\n/two/g"); }); test("lower editor text is stored per tab and restored", async ({ page }) => { await setDoc(page, mainEditor(page), ["tab zero text"]); await switchTo(page, 1); await setDoc(page, mainEditor(page), ["tab one text"]); await expect .poll(async () => (await storageJson(page, KEY.docs))?.[0]) .toBe("tab zero text"); expect((await storageJson(page, KEY.docs))[1]).toBe("tab one text"); expect(await storage(page, KEY.active)).toBe("1"); await reload(page); await expect(tabs(page).nth(1)).toHaveClass(/active/); // active tab restored expect(await docText(mainEditor(page))).toBe("tab one text"); await switchTo(page, 0); expect(await docText(mainEditor(page))).toBe("tab zero text"); }); test("tab titles and tab count are stored and restored", async ({ page }) => { await addTabBtn(page).click(); await tabLabel(page, 0).dblclick(); await page.locator("#tab-bar input").fill("Renamed"); await page.keyboard.press("Enter"); await expect .poll(() => storageJson(page, KEY.titles)) .toEqual(["Renamed", "Code Snippet", "Server Logs", "Dataset 4"]); await reload(page); await expect(tabs(page)).toHaveCount(4); await expect(tabLabel(page, 0)).toHaveText("Renamed"); await expect(tabLabel(page, 3)).toHaveText("Dataset 4"); }); test("highlights are re-applied after reload", async ({ page }) => { await setDoc(page, mainEditor(page), ["needle hay needle"]); await setDoc(page, topEditor(page), ["/needle/"]); await expect.poll(() => matchTexts(page, 0)).toEqual(["needle", "needle"]); await reload(page); await expect.poll(() => matchTexts(page, 0)).toEqual(["needle", "needle"]); }); test("clearing localStorage resets to defaults", async ({ page }) => { await setDoc(page, topEditor(page), ["/wiped/"]); await expect.poll(() => storage(page, KEY.patterns)).toBe("/wiped/"); await page.evaluate(() => localStorage.clear()); await reload(page); await expect(tabs(page)).toHaveCount(3); expect(await docText(topEditor(page))).not.toContain("wiped"); }); }); // ===================================================================== // 3. rename tab on double click // ===================================================================== test.describe("rename tab", () => { const input = (p: Page) => p.locator("#tab-bar input"); test("double click shows a focused input with the current name selected", async ({ page, }) => { await tabLabel(page, 0).dblclick(); await expect(input(page)).toBeVisible(); await expect(input(page)).toBeFocused(); await expect(input(page)).toHaveValue(DEFAULT_TITLES[0]); expect( await input(page).evaluate( (el: HTMLInputElement) => el.selectionEnd! - el.selectionStart!, ), ).toBe(DEFAULT_TITLES[0].length); }); test("Enter commits, trims, and persists across reload", async ({ page }) => { await tabLabel(page, 0).dblclick(); await input(page).fill(" My Tab "); await page.keyboard.press("Enter"); await expect(input(page)).toHaveCount(0); await expect(tabLabel(page, 0)).toHaveText("My Tab"); await expect .poll(async () => (await storageJson(page, KEY.titles))[0]) .toBe("My Tab"); await reload(page); await expect(tabLabel(page, 0)).toHaveText("My Tab"); }); test("Escape cancels", async ({ page }) => { await tabLabel(page, 0).dblclick(); await input(page).fill("nope"); await page.keyboard.press("Escape"); await expect(tabLabel(page, 0)).toHaveText(DEFAULT_TITLES[0]); }); test("blur commits", async ({ page }) => { await tabLabel(page, 0).dblclick(); await input(page).fill("Blurred"); await mainEditor(page).click(); await expect(tabLabel(page, 0)).toHaveText("Blurred"); }); test("empty / whitespace-only name keeps the old name", async ({ page }) => { await tabLabel(page, 0).dblclick(); await input(page).fill(" "); await page.keyboard.press("Enter"); await expect(tabLabel(page, 0)).toHaveText(DEFAULT_TITLES[0]); }); test("typing in the rename input (incl. Enter) never edits the editors", async ({ page, }) => { await setDoc(page, mainEditor(page), ["keep me"]); await setDoc(page, topEditor(page), ["/keep/"]); await tabLabel(page, 0).dblclick(); await input(page).fill("abc"); await page.keyboard.press("Enter"); expect(await docText(mainEditor(page))).toBe("keep me"); expect(await docText(topEditor(page))).toBe("/keep/"); }); test("renaming a non-active tab also works (and activates it)", async ({ page, }) => { await tabLabel(page, 2).dblclick(); await input(page).fill("Logs!"); await page.keyboard.press("Enter"); await expect(tabLabel(page, 2)).toHaveText("Logs!"); await expect(tabs(page).nth(2)).toHaveClass(/active/); }); test("clicking inside the rename input does not switch tabs", async ({ page, }) => { await tabLabel(page, 0).dblclick(); await input(page).click(); await expect(input(page)).toBeVisible(); await expect(tabs(page).nth(0)).toHaveClass(/active/); }); }); // ===================================================================== // 4. add / delete tab (with confirmation) // ===================================================================== test.describe("add and delete tabs", () => { test('add creates "Dataset N" with starter text and activates it', async ({ page, }) => { await addTabBtn(page).click(); await expect(tabs(page)).toHaveCount(4); await expect(tabLabel(page, 3)).toHaveText("Dataset 4"); await expect(tabs(page).nth(3)).toHaveClass(/active/); expect(await docText(mainEditor(page))).toBe(NEW_TAB_TEXT); await addTabBtn(page).click(); await expect(tabLabel(page, 4)).toHaveText("Dataset 5"); }); test("add keeps the previous tab content intact", async ({ page }) => { await setDoc(page, mainEditor(page), ["original"]); await addTabBtn(page).click(); await switchTo(page, 0); expect(await docText(mainEditor(page))).toBe("original"); }); test("delete shows confirm() with the tab name; cancelling keeps the tab", async ({ page, }) => { await closeTab(page, 1, false, "Code Snippet"); await expect(tabs(page)).toHaveCount(3); await expect(tabLabel(page, 1)).toHaveText("Code Snippet"); }); test("accepting removes the tab and persists", async ({ page }) => { await closeTab(page, 1, true, "Code Snippet"); await expect(tabs(page)).toHaveCount(2); await expect(tabLabel(page, 0)).toHaveText("Emails & IDs"); await expect(tabLabel(page, 1)).toHaveText("Server Logs"); await expect .poll(() => storageJson(page, KEY.titles)) .toEqual(["Emails & IDs", "Server Logs"]); expect((await storageJson(page, KEY.docs)).length).toBe(2); await reload(page); await expect(tabs(page)).toHaveCount(2); }); test("close click does not switch to the tab being closed when cancelled", async ({ page, }) => { await closeTab(page, 2, false); await expect(tabs(page).nth(0)).toHaveClass(/active/); }); test("deleting a tab BEFORE the active one keeps the same tab and content active", async ({ page, }) => { await switchTo(page, 2); const before = await docText(mainEditor(page)); await closeTab(page, 0, true); await expect(tabs(page)).toHaveCount(2); await expect(tabs(page).nth(1)).toHaveClass(/active/); await expect(tabLabel(page, 1)).toHaveText("Server Logs"); expect(await docText(mainEditor(page))).toBe(before); }); test("deleting the active LAST tab activates the new last tab", async ({ page, }) => { await switchTo(page, 2); await closeTab(page, 2, true); await expect(tabs(page)).toHaveCount(2); await expect(tabs(page).nth(1)).toHaveClass(/active/); await expect(tabLabel(page, 1)).toHaveText("Code Snippet"); }); test("deleting the active FIRST tab activates the tab that took its place", async ({ page, }) => { await closeTab(page, 0, true); await expect(tabs(page).nth(0)).toHaveClass(/active/); await expect(tabLabel(page, 0)).toHaveText("Code Snippet"); }); test("last remaining tab cannot be closed (no close button)", async ({ page, }) => { await closeTab(page, 0, true); await closeTab(page, 0, true); await expect(tabs(page)).toHaveCount(1); await expect(page.locator("#tab-bar .tab-close")).toHaveCount(0); }); test("deleted tab edits do not leak into remaining tabs", async ({ page, }) => { await switchTo(page, 1); await setDoc(page, mainEditor(page), ["doomed"]); await closeTab(page, 1, true); for (let i = 0; i < 2; i++) { await switchTo(page, i); expect(await docText(mainEditor(page))).not.toBe("doomed"); } }); }); // ===================================================================== // 5. fullscreen (body margin / padding / max-width) // ===================================================================== test.describe("fullscreen", () => { const bodyMetrics = (p: Page) => p.evaluate(() => { const cs = getComputedStyle(document.body); return { margin: [cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft], padding: [ cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft, ], maxWidth: cs.maxWidth, inline: ["margin", "padding", "max-width"].map((k) => document.body.style.getPropertyValue(k), ), }; }); const ZERO = ["0px", "0px", "0px", "0px"]; test("checkbox is unchecked by default", async ({ page }) => { await expect(fullscreen(page)).not.toBeChecked(); }); test("checking removes body margin & padding and lifts max-width", async ({ page, }) => { await fullscreen(page).check(); const m = await bodyMetrics(page); expect(m.margin).toEqual(ZERO); expect(m.padding).toEqual(ZERO); expect(m.maxWidth).toBe("none"); expect(m.inline).toEqual(["0px", "0px", "none"]); const box = (await page.locator("body").boundingBox())!; expect(box.x).toBe(0); const clientWidth = await page.evaluate( () => document.documentElement.clientWidth, ); expect(Math.round(box.width)).toBe(clientWidth); }); test("unchecking restores the original body styles exactly", async ({ page, }) => { const before = await bodyMetrics(page); await fullscreen(page).check(); await fullscreen(page).uncheck(); expect(await bodyMetrics(page)).toEqual(before); }); test("editors become at least as wide in fullscreen", async ({ page }) => { const wBefore = (await page.locator("#regex-editor").boundingBox())!.width; await fullscreen(page).check(); const wAfter = (await page.locator("#regex-editor").boundingBox())!.width; expect(wAfter).toBeGreaterThanOrEqual(wBefore); }); test("fullscreen survives tab switching, add/rename and typing", async ({ page, }) => { await fullscreen(page).check(); await switchTo(page, 1); await addTabBtn(page).click(); await setDoc(page, topEditor(page), ["/x/"]); await tabLabel(page, 0).dblclick(); await page.locator("#tab-bar input").fill("FS"); await page.keyboard.press("Enter"); const m = await bodyMetrics(page); expect(m.margin).toEqual(ZERO); expect(m.padding).toEqual(ZERO); await expect(fullscreen(page)).toBeChecked(); }); test("toggling repeatedly is stable", async ({ page }) => { const before = await bodyMetrics(page); for (let i = 0; i < 3; i++) { await fullscreen(page).check(); expect((await bodyMetrics(page)).padding).toEqual(ZERO); await fullscreen(page).uncheck(); } expect(await bodyMetrics(page)).toEqual(before); }); }); // ===================================================================== // 6. undo / redo / selection / cursor preserved across tab movement // (each tab has its own EditorState in the lower editor) // ===================================================================== test.describe("per-tab editor state", () => { const HISTORY_GAP = 700; // CodeMirror groups edits within 500ms into one undo step test("cursor position is preserved per tab", async ({ page }) => { await setDoc(page, mainEditor(page), [ "line one", "line two", "line three", ]); await page.keyboard.press("ControlOrMeta+Home"); await page.keyboard.press("ArrowDown"); for (let i = 0; i < 4; i++) await page.keyboard.press("ArrowRight"); const cursor0 = await selection(mainEditor(page)); expect(cursor0.head).toBe(9 + 4); expect(cursor0.anchor).toBe(cursor0.head); await switchTo(page, 1); await focusEditor(mainEditor(page)); await page.keyboard.press("ControlOrMeta+End"); const len1 = (await docText(mainEditor(page))).length; expect((await selection(mainEditor(page))).head).toBe(len1); await switchTo(page, 0); expect(await selection(mainEditor(page))).toEqual(cursor0); // typing at the restored position lands exactly there await focusEditor(mainEditor(page)); await page.keyboard.type("X"); expect(await docText(mainEditor(page))).toBe( "line one\nlineX two\nline three", ); await switchTo(page, 1); expect((await selection(mainEditor(page))).head).toBe(len1); }); test("selection range is preserved per tab", async ({ page }) => { await setDoc(page, mainEditor(page), ["hello brave new world"]); await page.keyboard.press("ControlOrMeta+Home"); for (let i = 0; i < 6; i++) await page.keyboard.press("ArrowRight"); for (let i = 0; i < 5; i++) await page.keyboard.press("Shift+ArrowRight"); // "brave" expect(await selection(mainEditor(page))).toEqual({ anchor: 6, head: 11 }); await switchTo(page, 1); await focusEditor(mainEditor(page)); await page.keyboard.press("ControlOrMeta+a"); const all1 = await selection(mainEditor(page)); expect(all1.anchor).toBe(0); expect(all1.head).toBeGreaterThan(0); await switchTo(page, 0); expect(await selection(mainEditor(page))).toEqual({ anchor: 6, head: 11 }); await switchTo(page, 1); expect(await selection(mainEditor(page))).toEqual(all1); // selection is real: typing replaces exactly the preserved range await switchTo(page, 0); await focusEditor(mainEditor(page)); await page.keyboard.type("BRAVE"); expect(await docText(mainEditor(page))).toBe("hello BRAVE new world"); }); test("selection direction (anchor vs head) is preserved", async ({ page, }) => { await setDoc(page, mainEditor(page), ["abcdefghij"]); await page.keyboard.press("ControlOrMeta+End"); for (let i = 0; i < 4; i++) await page.keyboard.press("Shift+ArrowLeft"); // backwards const sel = await selection(mainEditor(page)); expect(sel.head).toBeLessThan(sel.anchor); await switchTo(page, 1); await switchTo(page, 0); expect(await selection(mainEditor(page))).toEqual(sel); }); test("undo history is preserved per tab and does not leak between tabs", async ({ page, }) => { // tab 0: three separate undo steps await setDoc(page, mainEditor(page), ["base"]); await page.waitForTimeout(HISTORY_GAP); await page.keyboard.type(" one"); await page.waitForTimeout(HISTORY_GAP); await page.keyboard.type(" two"); await page.waitForTimeout(HISTORY_GAP); await page.keyboard.type(" three"); expect(await docText(mainEditor(page))).toBe("base one two three"); // tab 1: its own edit await switchTo(page, 1); const tab1Original = await docText(mainEditor(page)); await focusEditor(mainEditor(page)); await page.keyboard.press("ControlOrMeta+End"); await page.keyboard.type(" T1"); await page.waitForTimeout(HISTORY_GAP); expect(await docText(mainEditor(page))).toBe(tab1Original + " T1"); // back to tab 0: undo works step by step, with no tab-1 text involved await switchTo(page, 0); await focusEditor(mainEditor(page)); await page.keyboard.press("ControlOrMeta+z"); expect(await docText(mainEditor(page))).toBe("base one two"); await page.keyboard.press("ControlOrMeta+z"); expect(await docText(mainEditor(page))).toBe("base one"); // tab 1: its undo only removes its own edit await switchTo(page, 1); await focusEditor(mainEditor(page)); await page.keyboard.press("ControlOrMeta+z"); expect(await docText(mainEditor(page))).toBe(tab1Original); await page.keyboard.press("ControlOrMeta+z"); // nothing more to undo expect(await docText(mainEditor(page))).toBe(tab1Original); // tab 0 untouched by tab 1's undo await switchTo(page, 0); expect(await docText(mainEditor(page))).toBe("base one"); }); test("redo history is preserved across tab switches", async ({ page }) => { await setDoc(page, mainEditor(page), ["alpha"]); await page.waitForTimeout(HISTORY_GAP); await page.keyboard.type(" beta"); await page.waitForTimeout(HISTORY_GAP); await page.keyboard.type(" gamma"); await page.keyboard.press("ControlOrMeta+z"); await page.keyboard.press("ControlOrMeta+z"); expect(await docText(mainEditor(page))).toBe("alpha"); await switchTo(page, 2); await focusEditor(mainEditor(page)); await page.keyboard.type("noise"); await page.waitForTimeout(HISTORY_GAP); await switchTo(page, 0); expect(await docText(mainEditor(page))).toBe("alpha"); await focusEditor(mainEditor(page)); await redo(page); expect(await docText(mainEditor(page))).toBe("alpha beta"); await redo(page); expect(await docText(mainEditor(page))).toBe("alpha beta gamma"); }); test("undo restores selection/cursor consistently after returning to a tab", async ({ page, }) => { await setDoc(page, mainEditor(page), ["12345"]); await page.waitForTimeout(HISTORY_GAP); await page.keyboard.type("678"); await page.keyboard.press("Home"); const cursor = await selection(mainEditor(page)); await switchTo(page, 1); await switchTo(page, 0); expect(await selection(mainEditor(page))).toEqual(cursor); await focusEditor(mainEditor(page)); await page.keyboard.press("ControlOrMeta+z"); expect(await docText(mainEditor(page))).toBe("12345"); }); test("a newly added tab has a fresh history (undo is a no-op) and cursor at start", async ({ page, }) => { await setDoc(page, mainEditor(page), ["something"]); await addTabBtn(page).click(); await focusEditor(mainEditor(page)); expect(await selection(mainEditor(page))).toEqual({ anchor: 0, head: 0 }); await page.keyboard.press("ControlOrMeta+z"); expect(await docText(mainEditor(page))).toBe(NEW_TAB_TEXT); }); test("state survives many rapid tab switches", async ({ page }) => { await setDoc(page, mainEditor(page), ["AAAAAAAAAA"]); await page.keyboard.press("ControlOrMeta+Home"); for (let i = 0; i < 2; i++) await page.keyboard.press("ArrowRight"); for (let i = 0; i < 3; i++) await page.keyboard.press("Shift+ArrowRight"); const selA = await selection(mainEditor(page)); await switchTo(page, 1); await setDoc(page, mainEditor(page), ["BBBBBBBBBB"]); await page.keyboard.press("ControlOrMeta+Home"); for (let i = 0; i < 6; i++) await page.keyboard.press("ArrowRight"); const selB = await selection(mainEditor(page)); for (let i = 0; i < 5; i++) { await switchTo(page, 0); await switchTo(page, 1); } expect(await selection(mainEditor(page))).toEqual(selB); await switchTo(page, 0); expect(await selection(mainEditor(page))).toEqual(selA); }); test("highlights are refreshed per tab when switching (stale regex queries)", async ({ page, }) => { await setDoc(page, topEditor(page), ["/zqx/"]); await setDoc(page, mainEditor(page), ["zqx qqq"]); await expect.poll(() => matchTexts(page, 0)).toEqual(["zqx"]); await switchTo(page, 1); await expect(page.locator("#cm6-editor .cm-regex-match-0")).toHaveCount(0); // change the regex while on tab 1, then return to tab 0: must use the NEW regex await setDoc(page, topEditor(page), ["/qqq/"]); await switchTo(page, 0); await expect.poll(() => matchTexts(page, 0)).toEqual(["qqq"]); }); test("switching tabs does not alter the regex (top) editor", async ({ page, }) => { await setDoc(page, topEditor(page), ["/keep/", "/me/"]); await switchTo(page, 1); await switchTo(page, 2); expect(await docText(topEditor(page))).toBe("/keep/\n/me/"); }); });