summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHizbu Azka <hanifan.azka@gmail.com>2026-10-18 17:51:44 +0700
committerHizbu Azka <hanifan.azka@gmail.com>2026-10-18 17:51:44 +0700
commit95f466fae00a7db3d513d8ab986f5d6617d6c24f (patch)
tree289df0c5acab2aaf9cb6e270a0254fa168c6d209
parentd6db8204d42f678e2a32151e25ee61c4b0bfa705 (diff)
add e2eHEADmain
-rw-r--r--.gitignore9
-rw-r--r--_e2e/package-lock.json78
-rw-r--r--_e2e/package.json13
-rw-r--r--_e2e/playwright.config.ts80
-rw-r--r--_e2e/tests/regex-editor.spec.ts753
5 files changed, 933 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index 1e6ee05..80d19bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,12 @@ _site/
# Ignore folders generated by Bundler
.bundle/
vendor/
+
+# playwright
+node_modules/
+/_e2e/test-results/
+/_e2e/playwright-report/
+/_e2e/blob-report/
+/_e2e/playwright/.cache/
+/_e2e/playwright/.auth/
+
diff --git a/_e2e/package-lock.json b/_e2e/package-lock.json
new file mode 100644
index 0000000..8ffab32
--- /dev/null
+++ b/_e2e/package-lock.json
@@ -0,0 +1,78 @@
+{
+ "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
new file mode 100644
index 0000000..369ba46
--- /dev/null
+++ b/_e2e/package.json
@@ -0,0 +1,13 @@
+{
+ "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
new file mode 100644
index 0000000..1e97f70
--- /dev/null
+++ b/_e2e/playwright.config.ts
@@ -0,0 +1,80 @@
+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
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/');
+ });
+});