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/'); }); });