diff options
Diffstat (limited to '_e2e/tests/regex-editor.spec.ts')
| -rw-r--r-- | _e2e/tests/regex-editor.spec.ts | 753 |
1 files changed, 753 insertions, 0 deletions
diff --git a/_e2e/tests/regex-editor.spec.ts b/_e2e/tests/regex-editor.spec.ts new file mode 100644 index 0000000..1f3c0d3 --- /dev/null +++ b/_e2e/tests/regex-editor.spec.ts @@ -0,0 +1,753 @@ +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<string> => + 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/'); + }); +}); |