From 976c0d950461ab884a9d107659d3ba103d8cb269 Mon Sep 17 00:00:00 2001 From: Hizbu Azka Date: Wed, 21 Oct 2026 14:31:59 +0700 Subject: selfhost cm, add dark theme --- .gitignore | 13 +- _e2e/package-lock.json | 78 ---- _e2e/package.json | 13 - _e2e/playwright.config.ts | 80 ---- _e2e/tests/regex-editor.spec.ts | 753 ---------------------------------- _includes/cm-importmap.html | 14 + _posts/2026-10-17-regex.html | 55 ++- _tools/cm-esbuild/package-lock.json | 650 +++++++++++++++++++++++++++++ _tools/cm-esbuild/package.json | 26 ++ _tools/cm-esbuild/scripts/deploy.js | 37 ++ _tools/cm-esbuild/src/commands.js | 1 + _tools/cm-esbuild/src/language.js | 1 + _tools/cm-esbuild/src/state.js | 1 + _tools/cm-esbuild/src/theme.js | 1 + _tools/cm-esbuild/src/view.js | 1 + _tools/e2e/package-lock.json | 78 ++++ _tools/e2e/package.json | 13 + _tools/e2e/playwright.config.ts | 80 ++++ _tools/e2e/tests/regex-editor.spec.ts | 753 ++++++++++++++++++++++++++++++++++ assets/cm/chunk-7PWEZHZB.js | 5 + assets/cm/chunk-I3OVNVKL.js | 7 + assets/cm/chunk-JULZPKSW.js | 3 + assets/cm/commands.js | 1 + assets/cm/language.js | 1 + assets/cm/state.js | 1 + assets/cm/theme.js | 1 + assets/cm/view.js | 1 + 27 files changed, 1727 insertions(+), 941 deletions(-) delete mode 100644 _e2e/package-lock.json delete mode 100644 _e2e/package.json delete mode 100644 _e2e/playwright.config.ts delete mode 100644 _e2e/tests/regex-editor.spec.ts create mode 100644 _includes/cm-importmap.html create mode 100644 _tools/cm-esbuild/package-lock.json create mode 100644 _tools/cm-esbuild/package.json create mode 100644 _tools/cm-esbuild/scripts/deploy.js create mode 100644 _tools/cm-esbuild/src/commands.js create mode 100644 _tools/cm-esbuild/src/language.js create mode 100644 _tools/cm-esbuild/src/state.js create mode 100644 _tools/cm-esbuild/src/theme.js create mode 100644 _tools/cm-esbuild/src/view.js create mode 100644 _tools/e2e/package-lock.json create mode 100644 _tools/e2e/package.json create mode 100644 _tools/e2e/playwright.config.ts create mode 100644 _tools/e2e/tests/regex-editor.spec.ts create mode 100644 assets/cm/chunk-7PWEZHZB.js create mode 100644 assets/cm/chunk-I3OVNVKL.js create mode 100644 assets/cm/chunk-JULZPKSW.js create mode 100644 assets/cm/commands.js create mode 100644 assets/cm/language.js create mode 100644 assets/cm/state.js create mode 100644 assets/cm/theme.js create mode 100644 assets/cm/view.js diff --git a/.gitignore b/.gitignore index 80d19bf..7c7fcc7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,11 @@ _site/ vendor/ # playwright -node_modules/ -/_e2e/test-results/ -/_e2e/playwright-report/ -/_e2e/blob-report/ -/_e2e/playwright/.cache/ -/_e2e/playwright/.auth/ +_tools/*/node_modules/ +_tools/cm-esbuild/dist/ +_tools/e2e/playwright-report/ +_tools/e2e/test-results/ +_tools/e2e/blob-report/ +_tools/e2e/playwright/.cache/ +_tools/e2e/playwright/.auth/ diff --git a/_e2e/package-lock.json b/_e2e/package-lock.json deleted file mode 100644 index 8ffab32..0000000 --- a/_e2e/package-lock.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "name": "hizbu.com_test2", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "hizbu.com_test2", - "version": "1.0.0", - "devDependencies": { - "@playwright/test": "^1.64.1", - "@types/node": "^26.6.5" - } - }, - "node_modules/@playwright/test": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", - "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.64.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@types/node": { - "version": "26.6.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", - "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.9.0" - } - }, - "node_modules/playwright": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", - "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.64.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright-core": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", - "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/undici-types": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/_e2e/package.json b/_e2e/package.json deleted file mode 100644 index 369ba46..0000000 --- a/_e2e/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "hizbu.com_test2", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": {}, - "keywords": [], - "type": "commonjs", - "devDependencies": { - "@playwright/test": "^1.64.1", - "@types/node": "^26.6.5" - } -} diff --git a/_e2e/playwright.config.ts b/_e2e/playwright.config.ts deleted file mode 100644 index 1e97f70..0000000 --- a/_e2e/playwright.config.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './tests', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('')`. */ - // baseURL: 'http://localhost:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - webServer: { - command: 'jekyll serve --source .. --destination ../_site', - url: 'http://localhost:4000', - reuseExistingServer: !process.env.CI, - timeout: 60_000, - }, -}) diff --git a/_e2e/tests/regex-editor.spec.ts b/_e2e/tests/regex-editor.spec.ts deleted file mode 100644 index 1f3c0d3..0000000 --- a/_e2e/tests/regex-editor.spec.ts +++ /dev/null @@ -1,753 +0,0 @@ -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/'); - }); -}); diff --git a/_includes/cm-importmap.html b/_includes/cm-importmap.html new file mode 100644 index 0000000..c0c7c99 --- /dev/null +++ b/_includes/cm-importmap.html @@ -0,0 +1,14 @@ + + + + diff --git a/_posts/2026-10-17-regex.html b/_posts/2026-10-17-regex.html index b7e3855..b03779a 100644 --- a/_posts/2026-10-17-regex.html +++ b/_posts/2026-10-17-regex.html @@ -31,11 +31,23 @@ layout: post