summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--_includes/cm-importmap.html21
-rw-r--r--_layouts/default.html54
-rw-r--r--_posts/2026-10-17-regex.html746
-rw-r--r--_tools/cm-esbuild/scripts/deploy.js62
-rw-r--r--_tools/cm-esbuild/src/commands.js2
-rw-r--r--_tools/cm-esbuild/src/language.js2
-rw-r--r--_tools/cm-esbuild/src/state.js2
-rw-r--r--_tools/cm-esbuild/src/theme.js2
-rw-r--r--_tools/cm-esbuild/src/view.js2
-rw-r--r--_tools/e2e/playwright.config.ts22
-rw-r--r--_tools/e2e/tests/regex-editor.spec.ts695
11 files changed, 923 insertions, 687 deletions
diff --git a/_includes/cm-importmap.html b/_includes/cm-importmap.html
index c0c7c99..90bf32e 100644
--- a/_includes/cm-importmap.html
+++ b/_includes/cm-importmap.html
@@ -1,14 +1,23 @@
-<link rel="modulepreload" href="{{ '/assets/cm/chunk-7PWEZHZB.js' | relative_url }}">
-<link rel="modulepreload" href="{{ '/assets/cm/chunk-I3OVNVKL.js' | relative_url }}">
-<link rel="modulepreload" href="{{ '/assets/cm/chunk-JULZPKSW.js' | relative_url }}">
+<link
+ rel="modulepreload"
+ href="{{ '/assets/cm/chunk-7PWEZHZB.js' | relative_url }}"
+/>
+<link
+ rel="modulepreload"
+ href="{{ '/assets/cm/chunk-I3OVNVKL.js' | relative_url }}"
+/>
+<link
+ rel="modulepreload"
+ href="{{ '/assets/cm/chunk-JULZPKSW.js' | relative_url }}"
+/>
<script type="importmap">
-{
- "imports": {
+ {
+ "imports": {
"@codemirror/state": "{{ '/assets/cm/state.js' | relative_url }}?v=ea9398b90f",
"@codemirror/view": "{{ '/assets/cm/view.js' | relative_url }}?v=ea9398b90f",
"@codemirror/commands": "{{ '/assets/cm/commands.js' | relative_url }}?v=ea9398b90f",
"@codemirror/language": "{{ '/assets/cm/language.js' | relative_url }}?v=ea9398b90f",
"@uiw/codemirror-theme-github": "{{ '/assets/cm/theme.js' | relative_url }}?v=ea9398b90f"
+ }
}
-}
</script>
diff --git a/_layouts/default.html b/_layouts/default.html
index 04b2cf4..d7dd799 100644
--- a/_layouts/default.html
+++ b/_layouts/default.html
@@ -1,9 +1,9 @@
-<!DOCTYPE html>
-<html lang="{{ site.lang | default: "en-US" }}">
+<!doctype html>
+<html lang="{{ site.lang | default: 'en-US' }}">
<head>
- <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
- <meta charset="utf-8">
+ <meta charset="utf-8" />
<title>{{ page.title }} - {{ site.title }}</title>
<style>
:root {
@@ -19,31 +19,43 @@
body {
background: var(--bg-color);
color: var(--text-color);
- font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
+ sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 16px;
margin: 2em auto;
max-width: 800px;
padding: 1em;
line-height: 1.4;
- hyphens: auto
+ hyphens: auto;
}
</style>
</head>
<body>
-<!-- _layout/default.html BEGIN_content -->
-{{ content -}}
-<!-- _layout/default.html END_content -->
- <footer>
- <small title="Reflects the whole site, not this page. Individual pages may be unchanged since earlier versions.">
- {% if site.data.build %}
- site version
- <a href="https://hizbu.com/cgit/hizbu.com.git/commit/?id={{ site.data.build.commit_full }}"><code>{{ site.data.build.commit }}</code></a>
- · committed <time datetime="{{ site.data.build.date }}">{{ site.data.build.date }}</time>
- {% else %}
- site version <a href="https://hizbu.com/cgit/hizbu.com.git"><code>development</code></a>
- · committed <time datetime="1970-01-01 00:00 UTC">1970-01-01 00:00 UTC</time>
- {% endif %}
- </small>
- </footer>
+ <!-- _layout/default.html BEGIN_content -->
+ {{ content -}}
+ <!-- _layout/default.html END_content -->
+ <footer>
+ <small
+ title="Reflects the whole site, not this page. Individual pages may be unchanged since earlier versions."
+ >
+ {% if site.data.build %} site version
+ <a
+ href="https://hizbu.com/cgit/hizbu.com.git/commit/?id={{ site.data.build.commit_full }}"
+ ><code>{{ site.data.build.commit }}</code></a
+ >
+ · committed
+ <time datetime="{{ site.data.build.date }}"
+ >{{ site.data.build.date }}</time
+ >
+ {% else %} site version
+ <a href="https://hizbu.com/cgit/hizbu.com.git"
+ ><code>development</code></a
+ >
+ · committed
+ <time datetime="1970-01-01 00:00 UTC">1970-01-01 00:00 UTC</time>
+ {% endif %}
+ </small>
+ </footer>
</body>
</html>
diff --git a/_posts/2026-10-17-regex.html b/_posts/2026-10-17-regex.html
index b03779a..bb0ceb4 100644
--- a/_posts/2026-10-17-regex.html
+++ b/_posts/2026-10-17-regex.html
@@ -3,7 +3,7 @@ layout: post
---
<label>
- <input type="checkbox" id="fullscreen">
+ <input type="checkbox" id="fullscreen" />
Fullscreen
</label>
@@ -11,19 +11,18 @@ layout: post
const body = document.body;
const checkbox = document.getElementById("fullscreen");
checkbox.addEventListener("change", () => {
- if (checkbox.checked) {
- body.style.setProperty("margin", "0");
- body.style.setProperty("max-width", "none");
- body.style.setProperty("padding", "0");
- } else {
- body.style.removeProperty("margin");
- body.style.removeProperty("max-width");
- body.style.removeProperty("padding");
- }
+ if (checkbox.checked) {
+ body.style.setProperty("margin", "0");
+ body.style.setProperty("max-width", "none");
+ body.style.setProperty("padding", "0");
+ } else {
+ body.style.removeProperty("margin");
+ body.style.removeProperty("max-width");
+ body.style.removeProperty("padding");
+ }
});
</script>
-
<div id="loading">
<div class="spinner"></div>
<div>Loading editor…</div>
@@ -31,155 +30,194 @@ layout: post
<style>
:root {
- --border-color: #d0d7de;
- --tab-bg: #f6f8fa;
- --tab-active-bg: #ffffff;
- --editor-bg: #ffffff;
- --rename-bg: #ffffff;
- --rename-fg: #000000;
+ --border-color: #d0d7de;
+ --tab-bg: #f6f8fa;
+ --tab-active-bg: #ffffff;
+ --editor-bg: #ffffff;
+ --rename-bg: #ffffff;
+ --rename-fg: #000000;
}
@media (prefers-color-scheme: dark) {
- :root {
- --border-color: #2f2821;
- --tab-bg: #090705;
- --tab-active-bg: #000000;
- --editor-bg: #0d1117;
- --rename-bg: #0d1117;
- --rename-fg: #ffffff;
- }
+ :root {
+ --border-color: #2f2821;
+ --tab-bg: #090705;
+ --tab-active-bg: #000000;
+ --editor-bg: #0d1117;
+ --rename-bg: #0d1117;
+ --rename-fg: #ffffff;
+ }
}
#loading {
- position: absolute;
- inset: 0;
- z-index: 10;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 12px;
- background: var(--bg-color);
- font-size: 14px;
+ position: absolute;
+ inset: 0;
+ z-index: 10;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ background: var(--bg-color);
+ font-size: 14px;
}
#loading .spinner {
- width: 28px;
- height: 28px;
- border: 3px solid var(--border-color);
- border-top-color: #0969da;
- border-radius: 50%;
- animation: spin 0.8s linear infinite;
+ width: 28px;
+ height: 28px;
+ border: 3px solid var(--border-color);
+ border-top-color: #0969da;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+ @keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
}
- @keyframes spin { to { transform: rotate(360deg); } }
/* Match highlights (bottom editor) */
- .cm-regex-match-0 { background-color: rgba(9, 105, 218, 0.15); border-bottom: 2px solid #0969da; border-radius: 2px; }
- .cm-regex-match-1 { background-color: rgba(45, 164, 78, 0.15); border-bottom: 2px solid #2da44e; border-radius: 2px; }
- .cm-regex-match-2 { background-color: rgba(207, 34, 46, 0.15); border-bottom: 2px solid #cf222e; border-radius: 2px; }
- .cm-regex-match-3 { background-color: rgba(154, 103, 0, 0.15); border-bottom: 2px solid #9a6700; border-radius: 2px; }
- .cm-regex-match-4 { background-color: rgba(130, 80, 223, 0.15); border-bottom: 2px solid #8250df; border-radius: 2px; }
+ .cm-regex-match-0 {
+ background-color: rgba(9, 105, 218, 0.15);
+ border-bottom: 2px solid #0969da;
+ border-radius: 2px;
+ }
+ .cm-regex-match-1 {
+ background-color: rgba(45, 164, 78, 0.15);
+ border-bottom: 2px solid #2da44e;
+ border-radius: 2px;
+ }
+ .cm-regex-match-2 {
+ background-color: rgba(207, 34, 46, 0.15);
+ border-bottom: 2px solid #cf222e;
+ border-radius: 2px;
+ }
+ .cm-regex-match-3 {
+ background-color: rgba(154, 103, 0, 0.15);
+ border-bottom: 2px solid #9a6700;
+ border-radius: 2px;
+ }
+ .cm-regex-match-4 {
+ background-color: rgba(130, 80, 223, 0.15);
+ border-bottom: 2px solid #8250df;
+ border-radius: 2px;
+ }
/* Line fringe highlights (top editor) */
- .cm-regex-line-0 { border-left: 4px solid #0969da; background-color: rgba(9, 105, 218, 0.05); }
- .cm-regex-line-1 { border-left: 4px solid #2da44e; background-color: rgba(45, 164, 78, 0.05); }
- .cm-regex-line-2 { border-left: 4px solid #cf222e; background-color: rgba(207, 34, 46, 0.05); }
- .cm-regex-line-3 { border-left: 4px solid #9a6700; background-color: rgba(154, 103, 0, 0.05); }
- .cm-regex-line-4 { border-left: 4px solid #8250df; background-color: rgba(130, 80, 223, 0.05); }
+ .cm-regex-line-0 {
+ border-left: 4px solid #0969da;
+ background-color: rgba(9, 105, 218, 0.05);
+ }
+ .cm-regex-line-1 {
+ border-left: 4px solid #2da44e;
+ background-color: rgba(45, 164, 78, 0.05);
+ }
+ .cm-regex-line-2 {
+ border-left: 4px solid #cf222e;
+ background-color: rgba(207, 34, 46, 0.05);
+ }
+ .cm-regex-line-3 {
+ border-left: 4px solid #9a6700;
+ background-color: rgba(154, 103, 0, 0.05);
+ }
+ .cm-regex-line-4 {
+ border-left: 4px solid #8250df;
+ background-color: rgba(130, 80, 223, 0.05);
+ }
.tab-bar-container {
- display: flex;
- align-items: center;
- background: var(--border-color);
- padding: 1px 1px 0 1px;
- overflow: hidden;
+ display: flex;
+ align-items: center;
+ background: var(--border-color);
+ padding: 1px 1px 0 1px;
+ overflow: hidden;
}
.tab-bar {
- display: flex;
- gap: 1px;
- overflow-x: auto;
- flex: 1;
+ display: flex;
+ gap: 1px;
+ overflow-x: auto;
+ flex: 1;
}
.tab-btn {
- background: var(--tab-bg);
- color: var(--text-color);
- border: none;
- padding: 6px 10px 6px 14px;
- cursor: pointer;
- font-size: 13px;
- display: flex;
- align-items: center;
- gap: 8px;
- white-space: nowrap;
- user-select: none;
+ background: var(--tab-bg);
+ color: var(--text-color);
+ border: none;
+ padding: 6px 10px 6px 14px;
+ cursor: pointer;
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ white-space: nowrap;
+ user-select: none;
}
.tab-btn input {
- font: inherit;
- padding: 0 2px;
- border: 1px solid #0969da;
- outline: none;
- background: var(--rename-bg);
- color: var(--rename-fg);
+ font: inherit;
+ padding: 0 2px;
+ border: 1px solid #0969da;
+ outline: none;
+ background: var(--rename-bg);
+ color: var(--rename-fg);
}
.tab-btn.active {
- background: var(--tab-active-bg);
- border-bottom: 2px solid #0969da;
- font-weight: 500;
+ background: var(--tab-active-bg);
+ border-bottom: 2px solid #0969da;
+ font-weight: 500;
}
.tab-close {
- background: transparent;
- border: none;
- color: #8c959f;
- cursor: pointer;
- font-size: 14px;
- padding: 0 4px;
- border-radius: 4px;
+ background: transparent;
+ border: none;
+ color: #8c959f;
+ cursor: pointer;
+ font-size: 14px;
+ padding: 0 4px;
+ border-radius: 4px;
}
.tab-close:hover {
- background: rgba(128, 128, 128, 0.25);
- color: var(--text-color);
+ background: rgba(128, 128, 128, 0.25);
+ color: var(--text-color);
}
.tab-add-btn {
- background: var(--tab-bg);
- color: var(--text-color);
- border: none;
- cursor: pointer;
- font-size: 16px;
- font-weight: bold;
+ background: var(--tab-bg);
+ color: var(--text-color);
+ border: none;
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: bold;
}
.tab-add-btn:hover {
- background: var(--tab-active-bg);
+ background: var(--tab-active-bg);
}
#regex-editor {
- margin-bottom: 1rem;
- border: 1px solid var(--border-color);
- overflow: hidden;
+ margin-bottom: 1rem;
+ border: 1px solid var(--border-color);
+ overflow: hidden;
background: var(--editor-bg);
}
#cm6-editor {
- flex: 1;
- display: flex;
- flex-direction: column;
- border: 1px solid var(--border-color);
- overflow: hidden;
- background: var(--editor-bg);
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--border-color);
+ overflow: hidden;
+ background: var(--editor-bg);
}
#regex-editor .cm-editor,
#cm6-editor .cm-editor {
- height: 100%;
+ height: 100%;
}
#regex-editor .cm-scroller,
#cm6-editor .cm-scroller {
- overflow: auto;
+ overflow: auto;
}
- .cm-editor.cm-focused,
- .cm-scroller:focus,
+ .cm-editor.cm-focused,
+ .cm-scroller:focus,
.cm-content:focus {
- outline: none !important;
+ outline: none !important;
}
</style>
@@ -196,50 +234,74 @@ layout: post
{% include cm-importmap.html %}
<script type="module">
- import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state';
- import { EditorView, Decoration, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, highlightActiveLine } from '@codemirror/view';
- import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
- import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language';
+ import {
+ EditorState,
+ StateField,
+ StateEffect,
+ RangeSetBuilder,
+ Compartment,
+ } from "@codemirror/state";
+ import {
+ EditorView,
+ Decoration,
+ keymap,
+ lineNumbers,
+ highlightActiveLineGutter,
+ highlightSpecialChars,
+ drawSelection,
+ highlightActiveLine,
+ } from "@codemirror/view";
+ import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
+ import {
+ syntaxHighlighting,
+ defaultHighlightStyle,
+ bracketMatching,
+ } from "@codemirror/language";
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
- const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
- const themeFor = dark => dark ? githubDark : githubLight;
+ const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ const themeFor = (dark) => (dark ? githubDark : githubLight);
const regexTheme = new Compartment();
- const mainTheme = new Compartment();
+ const mainTheme = new Compartment();
// Top Editor Line Fringe Decorations
const topEditorLineDecos = [
- Decoration.line({ class: 'cm-regex-line-0' }),
- Decoration.line({ class: 'cm-regex-line-1' }),
- Decoration.line({ class: 'cm-regex-line-2' }),
- Decoration.line({ class: 'cm-regex-line-3' }),
- Decoration.line({ class: 'cm-regex-line-4' })
+ Decoration.line({ class: "cm-regex-line-0" }),
+ Decoration.line({ class: "cm-regex-line-1" }),
+ Decoration.line({ class: "cm-regex-line-2" }),
+ Decoration.line({ class: "cm-regex-line-3" }),
+ Decoration.line({ class: "cm-regex-line-4" }),
];
const topEditorField = StateField.define({
- create: state => buildTopDecorations(state),
- update: (decorations, tr) => tr.docChanged ? buildTopDecorations(tr.state) : decorations,
- provide: f => EditorView.decorations.from(f)
+ create: (state) => buildTopDecorations(state),
+ update: (decorations, tr) =>
+ tr.docChanged ? buildTopDecorations(tr.state) : decorations,
+ provide: (f) => EditorView.decorations.from(f),
});
function buildTopDecorations(state) {
- const builder = new RangeSetBuilder();
- for (let i = 1; i <= state.doc.lines; i++) {
- const line = state.doc.line(i);
- builder.add(line.from, line.from, topEditorLineDecos[(i - 1) % topEditorLineDecos.length]);
- }
- return builder.finish();
- }
-
-
+ const builder = new RangeSetBuilder();
+ for (let i = 1; i <= state.doc.lines; i++) {
+ const line = state.doc.line(i);
+ builder.add(
+ line.from,
+ line.from,
+ topEditorLineDecos[(i - 1) % topEditorLineDecos.length],
+ );
+ }
+ return builder.finish();
+ }
- // Load saved regex patterns or fallback to default
- const savedRegex = localStorage.getItem('regex_patterns') || "/([a-zA-Z0-9._-]+)@([a-zA-Z0-9._-]+)\\.([a-zA-Z]{2,5})/\n/([0-9]{4}-[0-9]{2}-[0-9]{2})/";
+ // Load saved regex patterns or fallback to default
+ const savedRegex =
+ localStorage.getItem("regex_patterns") ||
+ "/([a-zA-Z0-9._-]+)@([a-zA-Z0-9._-]+)\\.([a-zA-Z]{2,5})/\n/([0-9]{4}-[0-9]{2}-[0-9]{2})/";
- // Load saved tabs/documents or fallback to defaults
- let documents = JSON.parse(localStorage.getItem('regex_documents')) || [
- `Welcome to CodeMirror 6 Regex Visualizer!
+ // Load saved tabs/documents or fallback to defaults
+ let documents = JSON.parse(localStorage.getItem("regex_documents")) || [
+ `Welcome to CodeMirror 6 Regex Visualizer!
Test email addresses:
- Support Team: support@example.com
@@ -251,7 +313,7 @@ layout: post
- User ID: 1042 | Session Token: abc123XYZ
- Created on: 2026-10-17`,
- `function testAuth() {
+ `function testAuth() {
const token = "Bearer eyJhbGciOiJIUzI1NiIsIn";
let userId = 9921;
// TODO: sanitize input
@@ -259,268 +321,280 @@ layout: post
}
const date = "2026-10-18";`,
- `[2026-10-18 10:15:30] INFO: User login success (uid=402, ip=192.168.1.50)
+ `[2026-10-18 10:15:30] INFO: User login success (uid=402, ip=192.168.1.50)
[2026-10-18 10:16:02] ERROR: Invalid token provided (token=xyz_9988_abc)
- [2026-10-18 10:17:11] WARN: Rate limit approaching for service@api.net`
- ];
+ [2026-10-18 10:17:11] WARN: Rate limit approaching for service@api.net`,
+ ];
- let tabTitles = JSON.parse(localStorage.getItem('regex_tab_titles')) || ["Emails & IDs", "Code Snippet", "Server Logs"];
- let activeTabIndex = parseInt(localStorage.getItem('regex_active_tab')) || 0;
- if (activeTabIndex >= documents.length) activeTabIndex = 0;
+ let tabTitles = JSON.parse(localStorage.getItem("regex_tab_titles")) || [
+ "Emails & IDs",
+ "Code Snippet",
+ "Server Logs",
+ ];
+ let activeTabIndex = parseInt(localStorage.getItem("regex_active_tab")) || 0;
+ if (activeTabIndex >= documents.length) activeTabIndex = 0;
- function saveToStorage() {
+ function saveToStorage() {
// Keep current active tab text up to date in the array before saving
documents[activeTabIndex] = mainView.state.doc.toString();
-
- localStorage.setItem('regex_patterns', regexView.state.doc.toString());
- localStorage.setItem('regex_documents', JSON.stringify(documents));
- localStorage.setItem('regex_tab_titles', JSON.stringify(tabTitles));
- localStorage.setItem('regex_active_tab', activeTabIndex.toString());
- }
- const initialRegex = "/([a-zA-Z0-9.\-_]+)@([a-zA-Z0-9.\-_]+)\\.([a-zA-Z]{2,5})/\n/([0-9]{4}-[0-9]{2}-[0-9]{2})/";
+ localStorage.setItem("regex_patterns", regexView.state.doc.toString());
+ localStorage.setItem("regex_documents", JSON.stringify(documents));
+ localStorage.setItem("regex_tab_titles", JSON.stringify(tabTitles));
+ localStorage.setItem("regex_active_tab", activeTabIndex.toString());
+ }
- const regexView = new EditorView({
+ const initialRegex =
+ "/([a-zA-Z0-9.\-_]+)@([a-zA-Z0-9.\-_]+)\\.([a-zA-Z]{2,5})/\n/([0-9]{4}-[0-9]{2}-[0-9]{2})/";
+
+ const regexView = new EditorView({
state: EditorState.create({
- doc: savedRegex,
- extensions: [
- lineNumbers(),
- drawSelection(),
- keymap.of(defaultKeymap),
- regexTheme.of(themeFor(darkQuery.matches)),
- topEditorField,
- EditorView.updateListener.of(update => {
- if (update.docChanged) {
- dispatchRegexUpdate();
- saveToStorage();
- }
- })
- ]
+ doc: savedRegex,
+ extensions: [
+ lineNumbers(),
+ drawSelection(),
+ keymap.of(defaultKeymap),
+ regexTheme.of(themeFor(darkQuery.matches)),
+ topEditorField,
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged) {
+ dispatchRegexUpdate();
+ saveToStorage();
+ }
+ }),
+ ],
}),
- parent: document.getElementById('regex-editor')
- });
+ parent: document.getElementById("regex-editor"),
+ });
- const tabBarEl = document.getElementById('tab-bar');
+ const tabBarEl = document.getElementById("tab-bar");
- function renderTabs() {
- tabBarEl.innerHTML = '';
+ function renderTabs() {
+ tabBarEl.innerHTML = "";
documents.forEach((_, index) => {
- const tab = document.createElement('div');
- tab.className = 'tab-btn' + (index === activeTabIndex ? ' active' : '');
- tab.setAttribute('role', 'tab');
-
- const label = document.createElement('span');
- label.textContent = tabTitles[index] || `Tab ${index + 1}`;
- label.title = 'Double-click to rename';
- label.addEventListener('dblclick', () => renameTab(index, label));
- tab.appendChild(label);
-
- if (documents.length > 1) {
- const closeBtn = document.createElement('span');
- closeBtn.className = 'tab-close';
- closeBtn.setAttribute('role', 'button');
- closeBtn.title = 'Close Tab';
- closeBtn.textContent = '×';
- closeBtn.addEventListener('click', e => { e.stopPropagation(); closeTab(index); });
- tab.appendChild(closeBtn);
- }
+ const tab = document.createElement("div");
+ tab.className = "tab-btn" + (index === activeTabIndex ? " active" : "");
+ tab.setAttribute("role", "tab");
+
+ const label = document.createElement("span");
+ label.textContent = tabTitles[index] || `Tab ${index + 1}`;
+ label.title = "Double-click to rename";
+ label.addEventListener("dblclick", () => renameTab(index, label));
+ tab.appendChild(label);
+
+ if (documents.length > 1) {
+ const closeBtn = document.createElement("span");
+ closeBtn.className = "tab-close";
+ closeBtn.setAttribute("role", "button");
+ closeBtn.title = "Close Tab";
+ closeBtn.textContent = "×";
+ closeBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ closeTab(index);
+ });
+ tab.appendChild(closeBtn);
+ }
- tab.addEventListener('click', () => switchTab(index));
- tabBarEl.appendChild(tab);
+ tab.addEventListener("click", () => switchTab(index));
+ tabBarEl.appendChild(tab);
});
- }
+ }
- function renameTab(index, label) {
- const input = document.createElement('input');
- input.value = tabTitles[index] || '';
+ function renameTab(index, label) {
+ const input = document.createElement("input");
+ input.value = tabTitles[index] || "";
input.size = Math.max(6, input.value.length);
label.replaceWith(input);
input.focus();
input.select();
let done = false;
- const finish = save => {
- if (done) return; // blur also fires when the input is removed
- done = true;
- const name = input.value.trim();
- if (save && name) tabTitles[index] = name; // empty name keeps the old one
- renderTabs();
- saveToStorage();
+ const finish = (save) => {
+ if (done) return; // blur also fires when the input is removed
+ done = true;
+ const name = input.value.trim();
+ if (save && name) tabTitles[index] = name; // empty name keeps the old one
+ renderTabs();
+ saveToStorage();
};
- input.addEventListener('keydown', e => {
- e.stopPropagation();
- if (e.key === 'Enter') finish(true);
- if (e.key === 'Escape') finish(false);
+ input.addEventListener("keydown", (e) => {
+ e.stopPropagation();
+ if (e.key === "Enter") finish(true);
+ if (e.key === "Escape") finish(false);
});
- input.addEventListener('blur', () => finish(true));
- input.addEventListener('click', e => e.stopPropagation()); // don't trigger switchTab
- }
+ input.addEventListener("blur", () => finish(true));
+ input.addEventListener("click", (e) => e.stopPropagation()); // don't trigger switchTab
+ }
function applyTheme() {
- const t = themeFor(darkQuery.matches);
- regexView.dispatch({ effects: regexTheme.reconfigure(t) });
- mainView.dispatch({ effects: mainTheme.reconfigure(t) });
+ const t = themeFor(darkQuery.matches);
+ regexView.dispatch({ effects: regexTheme.reconfigure(t) });
+ mainView.dispatch({ effects: mainTheme.reconfigure(t) });
}
- darkQuery.addEventListener('change', applyTheme);
+ darkQuery.addEventListener("change", applyTheme);
- function loadActive() {
+ function loadActive() {
renderTabs();
mainView.setState(states[activeTabIndex]);
- applyTheme();
- dispatchRegexUpdate(); // a stashed state may hold stale regex queries
- saveToStorage(); // must run after setState so it saves the new tab's text
- }
+ applyTheme();
+ dispatchRegexUpdate(); // a stashed state may hold stale regex queries
+ saveToStorage(); // must run after setState so it saves the new tab's text
+ }
- function switchTab(index) {
+ function switchTab(index) {
if (index === activeTabIndex) return;
- states[activeTabIndex] = mainView.state; // stash undo history + cursor
+ states[activeTabIndex] = mainView.state; // stash undo history + cursor
activeTabIndex = index;
loadActive();
- }
+ }
- function closeTab(index) {
+ function closeTab(index) {
const tabName = tabTitles[index] || `Tab ${index + 1}`;
if (!window.confirm(`Are you sure you want to close "${tabName}"?`)) return;
states[activeTabIndex] = mainView.state;
- [documents, tabTitles, states].forEach(a => a.splice(index, 1));
-
- if (index < activeTabIndex) activeTabIndex--; // also fixes the wrong-tab bug
- else activeTabIndex = Math.min(activeTabIndex, documents.length - 1);
- loadActive();
- }
-
- document.getElementById('add-tab-btn').addEventListener('click', () => {
- const text = "// Type or paste text here...\n";
- states[activeTabIndex] = mainView.state;
- documents.push(text);
- tabTitles.push(`Dataset ${documents.length}`);
- states.push(makeState(text));
- activeTabIndex = documents.length - 1;
- loadActive();
- });
+ [documents, tabTitles, states].forEach((a) => a.splice(index, 1));
- // Initial Render Call
- renderTabs();
-
- const updateRegexEffect = StateEffect.define();
-
- const regexMatchDecos = [
- Decoration.mark({ class: 'cm-regex-match-0' }),
- Decoration.mark({ class: 'cm-regex-match-1' }),
- Decoration.mark({ class: 'cm-regex-match-2' }),
- Decoration.mark({ class: 'cm-regex-match-3' }),
- Decoration.mark({ class: 'cm-regex-match-4' })
- ];
-
- const regexHighlightField = StateField.define({
- create: () => ({ decorations: Decoration.none, query: [] }),
- update(value, tr) {
+ if (index < activeTabIndex)
+ activeTabIndex--; // also fixes the wrong-tab bug
+ else activeTabIndex = Math.min(activeTabIndex, documents.length - 1);
+ loadActive();
+ }
+
+ document.getElementById("add-tab-btn").addEventListener("click", () => {
+ const text = "// Type or paste text here...\n";
+ states[activeTabIndex] = mainView.state;
+ documents.push(text);
+ tabTitles.push(`Dataset ${documents.length}`);
+ states.push(makeState(text));
+ activeTabIndex = documents.length - 1;
+ loadActive();
+ });
+
+ // Initial Render Call
+ renderTabs();
+
+ const updateRegexEffect = StateEffect.define();
+
+ const regexMatchDecos = [
+ Decoration.mark({ class: "cm-regex-match-0" }),
+ Decoration.mark({ class: "cm-regex-match-1" }),
+ Decoration.mark({ class: "cm-regex-match-2" }),
+ Decoration.mark({ class: "cm-regex-match-3" }),
+ Decoration.mark({ class: "cm-regex-match-4" }),
+ ];
+
+ const regexHighlightField = StateField.define({
+ create: () => ({ decorations: Decoration.none, query: [] }),
+ update(value, tr) {
let currentQueries = value.query;
for (let effect of tr.effects) {
- if (effect.is(updateRegexEffect)) currentQueries = effect.value;
+ if (effect.is(updateRegexEffect)) currentQueries = effect.value;
}
if (!tr.docChanged && tr.effects.length === 0) return value;
const docText = tr.state.doc.toString();
const ranges = [];
- currentQueries.forEach(q => {
- if (!q.pattern) return;
- let activeRegex;
- try {
- activeRegex = new RegExp(q.pattern, q.flags);
- } catch (err) {
- return;
- }
-
- let match;
- const deco = regexMatchDecos[q.lineIndex % regexMatchDecos.length];
-
- if (q.flags.includes('g')) {
- while ((match = activeRegex.exec(docText)) !== null) {
- const fullStart = match.index;
- const fullEnd = fullStart + match[0].length;
- if (fullStart !== fullEnd) {
- ranges.push({ from: fullStart, to: fullEnd, deco });
- }
- if (match.index === activeRegex.lastIndex) {
- activeRegex.lastIndex++;
- }
- }
- } else {
- match = activeRegex.exec(docText);
- if (match) {
- const fullStart = match.index;
- const fullEnd = fullStart + match[0].length;
- if (fullStart !== fullEnd) {
- ranges.push({ from: fullStart, to: fullEnd, deco });
- }
- }
- }
+ currentQueries.forEach((q) => {
+ if (!q.pattern) return;
+ let activeRegex;
+ try {
+ activeRegex = new RegExp(q.pattern, q.flags);
+ } catch (err) {
+ return;
+ }
+
+ let match;
+ const deco = regexMatchDecos[q.lineIndex % regexMatchDecos.length];
+
+ if (q.flags.includes("g")) {
+ while ((match = activeRegex.exec(docText)) !== null) {
+ const fullStart = match.index;
+ const fullEnd = fullStart + match[0].length;
+ if (fullStart !== fullEnd) {
+ ranges.push({ from: fullStart, to: fullEnd, deco });
+ }
+ if (match.index === activeRegex.lastIndex) {
+ activeRegex.lastIndex++;
+ }
+ }
+ } else {
+ match = activeRegex.exec(docText);
+ if (match) {
+ const fullStart = match.index;
+ const fullEnd = fullStart + match[0].length;
+ if (fullStart !== fullEnd) {
+ ranges.push({ from: fullStart, to: fullEnd, deco });
+ }
+ }
+ }
});
ranges.sort((a, b) => a.from - b.from || a.to - b.to);
const builder = new RangeSetBuilder();
- ranges.forEach(r => builder.add(r.from, r.to, r.deco));
+ ranges.forEach((r) => builder.add(r.from, r.to, r.deco));
return { decorations: builder.finish(), query: currentQueries };
- },
- provide: f => EditorView.decorations.from(f, val => val.decorations)
- });
+ },
+ provide: (f) => EditorView.decorations.from(f, (val) => val.decorations),
+ });
- const mainExtensions = [
- lineNumbers(),
- highlightActiveLineGutter(),
- highlightSpecialChars(),
- history(),
- drawSelection(),
- highlightActiveLine(),
- bracketMatching(),
- mainTheme.of(themeFor(darkQuery.matches)),
- // syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
- keymap.of([...defaultKeymap, ...historyKeymap]),
- regexHighlightField,
- EditorView.updateListener.of(u => { if (u.docChanged) saveToStorage(); })
- ];
-
- const makeState = doc => EditorState.create({ doc, extensions: mainExtensions });
- const states = documents.map(makeState); // one EditorState per tab, parallel to documents/tabTitles
-
- const mainView = new EditorView({
- state: states[activeTabIndex],
- parent: document.getElementById('cm6-editor')
- });
+ const mainExtensions = [
+ lineNumbers(),
+ highlightActiveLineGutter(),
+ highlightSpecialChars(),
+ history(),
+ drawSelection(),
+ highlightActiveLine(),
+ bracketMatching(),
+ mainTheme.of(themeFor(darkQuery.matches)),
+ // syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
+ keymap.of([...defaultKeymap, ...historyKeymap]),
+ regexHighlightField,
+ EditorView.updateListener.of((u) => {
+ if (u.docChanged) saveToStorage();
+ }),
+ ];
+
+ const makeState = (doc) =>
+ EditorState.create({ doc, extensions: mainExtensions });
+ const states = documents.map(makeState); // one EditorState per tab, parallel to documents/tabTitles
- function dispatchRegexUpdate() {
- const lines = regexView.state.doc.toString().split('\n');
- const queries = [];
+ const mainView = new EditorView({
+ state: states[activeTabIndex],
+ parent: document.getElementById("cm6-editor"),
+ });
- lines.forEach((lineText, index) => {
+ function dispatchRegexUpdate() {
+ const lines = regexView.state.doc.toString().split("\n");
+ const queries = [];
+
+ lines.forEach((lineText, index) => {
const trimmed = lineText.trim();
if (!trimmed) return;
let pattern = trimmed;
- let flags = 'g';
+ let flags = "g";
- const lastSlashIndex = trimmed.lastIndexOf('/');
- if (trimmed.startsWith('/') && lastSlashIndex > 0) {
- pattern = trimmed.substring(1, lastSlashIndex);
- flags = trimmed.substring(lastSlashIndex + 1) || 'g';
+ const lastSlashIndex = trimmed.lastIndexOf("/");
+ if (trimmed.startsWith("/") && lastSlashIndex > 0) {
+ pattern = trimmed.substring(1, lastSlashIndex);
+ flags = trimmed.substring(lastSlashIndex + 1) || "g";
}
queries.push({ pattern, flags, lineIndex: index });
- });
+ });
- mainView.dispatch({
- effects: updateRegexEffect.of(queries)
- });
- }
+ mainView.dispatch({
+ effects: updateRegexEffect.of(queries),
+ });
+ }
- // Initial setup call
- dispatchRegexUpdate();
+ // Initial setup call
+ dispatchRegexUpdate();
- document.getElementById('loading').remove();
+ document.getElementById("loading").remove();
</script>
diff --git a/_tools/cm-esbuild/scripts/deploy.js b/_tools/cm-esbuild/scripts/deploy.js
index a06130e..2dcae72 100644
--- a/_tools/cm-esbuild/scripts/deploy.js
+++ b/_tools/cm-esbuild/scripts/deploy.js
@@ -1,37 +1,55 @@
-const fs = require('fs'), path = require('path'), crypto = require('crypto');
+const fs = require("fs"),
+ path = require("path"),
+ crypto = require("crypto");
const site = process.argv[2];
-if (!site) { console.error('usage: node scripts/publish.js <path-to-jekyll-site>'); process.exit(1); }
+if (!site) {
+ console.error("usage: node scripts/publish.js <path-to-jekyll-site>");
+ process.exit(1);
+}
-const dist = path.join(__dirname, '..', 'dist');
-const dest = path.join(site, 'assets', 'cm');
-const jsFiles = fs.readdirSync(dist).filter(f => f.endsWith('.js')).sort();
+const dist = path.join(__dirname, "..", "dist");
+const dest = path.join(site, "assets", "cm");
+const jsFiles = fs
+ .readdirSync(dist)
+ .filter((f) => f.endsWith(".js"))
+ .sort();
// version = hash of the bundle contents, so it only changes when the code does
-const h = crypto.createHash('sha1');
+const h = crypto.createHash("sha1");
for (const f of jsFiles) h.update(fs.readFileSync(path.join(dist, f)));
-const v = h.digest('hex').slice(0, 10);
+const v = h.digest("hex").slice(0, 10);
// replace old files so stale chunks don't pile up
fs.rmSync(dest, { recursive: true, force: true });
fs.mkdirSync(dest, { recursive: true });
-for (const f of fs.readdirSync(dist)) fs.copyFileSync(path.join(dist, f), path.join(dest, f));
+for (const f of fs.readdirSync(dist))
+ fs.copyFileSync(path.join(dist, f), path.join(dest, f));
const entries = {
- '@codemirror/state': 'state',
- '@codemirror/view': 'view',
- '@codemirror/commands': 'commands',
- '@codemirror/language': 'language',
- '@uiw/codemirror-theme-github': 'theme',
+ "@codemirror/state": "state",
+ "@codemirror/view": "view",
+ "@codemirror/commands": "commands",
+ "@codemirror/language": "language",
+ "@uiw/codemirror-theme-github": "theme",
};
const imports = Object.entries(entries)
- .map(([spec, name]) => ` "${spec}": "{{ '/assets/cm/${name}.js' | relative_url }}?v=${v}"`)
- .join(',\n');
-const preload = jsFiles.filter(f => f.startsWith('chunk-'))
- .map(f => `<link rel="modulepreload" href="{{ '/assets/cm/${f}' | relative_url }}">`)
- .join('\n');
+ .map(
+ ([spec, name]) =>
+ ` "${spec}": "{{ '/assets/cm/${name}.js' | relative_url }}?v=${v}"`,
+ )
+ .join(",\n");
+const preload = jsFiles
+ .filter((f) => f.startsWith("chunk-"))
+ .map(
+ (f) => `<link
+ rel="modulepreload"
+ href="{{ '/assets/cm/${f}' | relative_url }}"
+/>`,
+ )
+ .join("\n");
-const html = `${preload}\n<script type="importmap">\n{\n "imports": {\n${imports}\n }\n}\n</script>\n`;
-fs.mkdirSync(path.join(site, '_includes'), { recursive: true });
-fs.writeFileSync(path.join(site, '_includes', 'cm-importmap.html'), html);
-console.log('published, version', v);
+const html = `${preload}\n<script type="importmap">\n {\n "imports": {\n${imports}\n }\n }\n</script>\n`;
+fs.mkdirSync(path.join(site, "_includes"), { recursive: true });
+fs.writeFileSync(path.join(site, "_includes", "cm-importmap.html"), html);
+console.log("published, version", v);
diff --git a/_tools/cm-esbuild/src/commands.js b/_tools/cm-esbuild/src/commands.js
index 34031c2..1862ceb 100644
--- a/_tools/cm-esbuild/src/commands.js
+++ b/_tools/cm-esbuild/src/commands.js
@@ -1 +1 @@
-export * from '@codemirror/commands';
+export * from "@codemirror/commands";
diff --git a/_tools/cm-esbuild/src/language.js b/_tools/cm-esbuild/src/language.js
index 9bfe6b6..632a9f7 100644
--- a/_tools/cm-esbuild/src/language.js
+++ b/_tools/cm-esbuild/src/language.js
@@ -1 +1 @@
-export * from '@codemirror/language';
+export * from "@codemirror/language";
diff --git a/_tools/cm-esbuild/src/state.js b/_tools/cm-esbuild/src/state.js
index b6ef173..9dd5c97 100644
--- a/_tools/cm-esbuild/src/state.js
+++ b/_tools/cm-esbuild/src/state.js
@@ -1 +1 @@
-export * from '@codemirror/state';
+export * from "@codemirror/state";
diff --git a/_tools/cm-esbuild/src/theme.js b/_tools/cm-esbuild/src/theme.js
index d7b898a..42f56ae 100644
--- a/_tools/cm-esbuild/src/theme.js
+++ b/_tools/cm-esbuild/src/theme.js
@@ -1 +1 @@
-export * from '@uiw/codemirror-theme-github';
+export * from "@uiw/codemirror-theme-github";
diff --git a/_tools/cm-esbuild/src/view.js b/_tools/cm-esbuild/src/view.js
index 6d964dc..3669d05 100644
--- a/_tools/cm-esbuild/src/view.js
+++ b/_tools/cm-esbuild/src/view.js
@@ -1 +1 @@
-export * from '@codemirror/view';
+export * from "@codemirror/view";
diff --git a/_tools/e2e/playwright.config.ts b/_tools/e2e/playwright.config.ts
index 1e97f70..fdd7826 100644
--- a/_tools/e2e/playwright.config.ts
+++ b/_tools/e2e/playwright.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig, devices } from '@playwright/test';
+import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
@@ -12,7 +12,7 @@ import { defineConfig, devices } from '@playwright/test';
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
- testDir: './tests',
+ 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. */
@@ -22,26 +22,26 @@ export default defineConfig({
/* 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',
+ 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',
+ trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
- name: 'chromium',
- use: { ...devices['Desktop Chrome'] },
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
},
{
- name: 'firefox',
- use: { ...devices['Desktop Firefox'] },
+ name: "firefox",
+ use: { ...devices["Desktop Firefox"] },
},
// {
@@ -72,9 +72,9 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
- command: 'jekyll serve --source .. --destination ../_site',
- url: 'http://localhost:4000',
+ command: "jekyll serve --source .. --destination ../_site",
+ url: "http://localhost:4000",
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
-})
+});
diff --git a/_tools/e2e/tests/regex-editor.spec.ts b/_tools/e2e/tests/regex-editor.spec.ts
index 1f3c0d3..fd0a04f 100644
--- a/_tools/e2e/tests/regex-editor.spec.ts
+++ b/_tools/e2e/tests/regex-editor.spec.ts
@@ -1,4 +1,4 @@
-import { test, expect, Page, Locator, Dialog } from '@playwright/test';
+import { test, expect, Page, Locator, Dialog } from "@playwright/test";
/**
* Playwright spec for https://hizbu.com/regex/
@@ -11,33 +11,39 @@ import { test, expect, Page, Locator, Dialog } from '@playwright/test';
* 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 URL = "http://localhost:4000/regex/";
const KEY = {
- patterns: 'regex_patterns',
- docs: 'regex_documents',
- titles: 'regex_tab_titles',
- active: 'regex_active_tab',
+ 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';
+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 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 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'); }
+ 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 }> =>
@@ -47,13 +53,16 @@ const selection = (l: Locator): Promise<{ anchor: number; head: number }> =>
});
/** Focus the editor WITHOUT changing its selection (unlike click). */
-const focusEditor = (l: Locator) => l.evaluate((el: any) => (window as any).__cmView(el).focus());
+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');
+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(p.locator("#loading")).toHaveCount(0, { timeout: 30_000 });
await expect(topEditor(p)).toBeVisible();
await expect(mainEditor(p)).toBeVisible();
}
@@ -66,10 +75,10 @@ async function reload(p: Page) {
/** 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');
+ 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 (i > 0) await p.keyboard.press("Enter");
if (lines[i]) await p.keyboard.type(lines[i]);
}
}
@@ -81,11 +90,16 @@ 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');
+ 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) {
+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 }[] = [];
@@ -93,16 +107,19 @@ async function closeTab(p: Page, index: number, accept: boolean, expectedName?:
seen.push({ type: d.type(), message: d.message() });
await (accept ? d.accept() : d.dismiss());
};
- p.on('dialog', handler);
+ p.on("dialog", handler);
try {
await tabClose(p, index).click();
} finally {
- p.off('dialog', handler);
+ 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}"?`);
+ 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 }) => {
@@ -112,7 +129,10 @@ test.beforeEach(async ({ page }) => {
(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');
+ if (!v)
+ throw new Error(
+ "Could not locate CodeMirror EditorView on .cm-content",
+ );
return v;
};
});
@@ -124,260 +144,312 @@ test.beforeEach(async ({ page }) => {
// =====================================================================
// 0. initial state
// =====================================================================
-test.describe('initial state', () => {
- test('loads default tabs, regexes and highlights', async ({ page }) => {
+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]);
+ 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');
+ 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']);
+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: [' ', ' ', ' '] },
+ { 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');
+ 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}`));
+ 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);
+ 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']);
+ 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']);
+ 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']);
+ 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']);
+ 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);
+ 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);
+ 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']);
+ 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']);
+ 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/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/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']);
+ 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 }) => {
+ test("invalid regex line is ignored without breaking other lines", async ({
+ page,
+ }) => {
const errors: string[] = [];
- page.on('pageerror', (e) => errors.push(e.message));
+ page.on("pageerror", (e) => errors.push(e.message));
- await setDoc(page, mainEditor(page), ['abc 123']);
- await setDoc(page, topEditor(page), ['/(/', '/123/']);
+ await setDoc(page, mainEditor(page), ["abc 123"]);
+ await setDoc(page, topEditor(page), ["/(/", "/123/"]);
- await expect.poll(() => matchTexts(page, 1)).toEqual(['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');
+ 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}`));
+ 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 }) => {
+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/');
+ 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 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');
+ 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');
+ 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']);
+ 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 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 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');
+ expect(await docText(mainEditor(page))).toBe("tab one text");
await switchTo(page, 0);
- expect(await docText(mainEditor(page))).toBe('tab zero text');
+ expect(await docText(mainEditor(page))).toBe("tab zero text");
});
- test('tab titles and tab count are stored and restored', async ({ page }) => {
+ 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 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 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');
+ 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']);
+ 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']);
+ 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/');
+ 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');
+ 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.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 }) => {
+ 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);
+ 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 }) => {
+ 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 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 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');
+ await expect(tabLabel(page, 0)).toHaveText("My Tab");
});
- test('Escape cancels', async ({ page }) => {
+ test("Escape cancels", async ({ page }) => {
await tabLabel(page, 0).dblclick();
- await input(page).fill('nope');
- await page.keyboard.press('Escape');
+ await input(page).fill("nope");
+ await page.keyboard.press("Escape");
await expect(tabLabel(page, 0)).toHaveText(DEFAULT_TITLES[0]);
});
- test('blur commits', async ({ page }) => {
+ test("blur commits", async ({ page }) => {
await tabLabel(page, 0).dblclick();
- await input(page).fill('Blurred');
+ await input(page).fill("Blurred");
await mainEditor(page).click();
- await expect(tabLabel(page, 0)).toHaveText('Blurred');
+ await expect(tabLabel(page, 0)).toHaveText("Blurred");
});
- test('empty / whitespace-only name keeps the old name', async ({ page }) => {
+ 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 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/']);
+ 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/');
+ 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 }) => {
+ 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 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 }) => {
+ 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();
@@ -388,86 +460,104 @@ test.describe('rename tab', () => {
// =====================================================================
// 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 }) => {
+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(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');
+ await expect(tabLabel(page, 4)).toHaveText("Dataset 5");
});
- test('add keeps the previous tab content intact', async ({ page }) => {
- await setDoc(page, mainEditor(page), ['original']);
+ 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');
+ 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');
+ 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');
+ await expect(tabLabel(page, 1)).toHaveText("Code Snippet");
});
- test('accepting removes the tab and persists', async ({ page }) => {
- await closeTab(page, 1, true, '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(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']);
+ 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 }) => {
+ 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 }) => {
+ 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');
+ 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 }) => {
+ 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');
+ await expect(tabLabel(page, 1)).toHaveText("Code Snippet");
});
- test('deleting the active FIRST tab activates the tab that took its place', async ({ page }) => {
+ 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');
+ await expect(tabLabel(page, 0)).toHaveText("Code Snippet");
});
- test('last remaining tab cannot be closed (no close button)', async ({ page }) => {
+ 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);
+ await expect(page.locator("#tab-bar .tab-close")).toHaveCount(0);
});
- test('deleted tab edits do not leak into remaining tabs', async ({ page }) => {
+ test("deleted tab edits do not leak into remaining tabs", async ({
+ page,
+ }) => {
await switchTo(page, 1);
- await setDoc(page, mainEditor(page), ['doomed']);
+ 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');
+ expect(await docText(mainEditor(page))).not.toBe("doomed");
}
});
});
@@ -475,59 +565,74 @@ test.describe('add and delete tabs', () => {
// =====================================================================
// 5. fullscreen (body margin / padding / max-width)
// =====================================================================
-test.describe('fullscreen', () => {
+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],
+ padding: [
+ cs.paddingTop,
+ cs.paddingRight,
+ cs.paddingBottom,
+ cs.paddingLeft,
+ ],
maxWidth: cs.maxWidth,
- inline: ['margin', 'padding', 'max-width'].map((k) => document.body.style.getPropertyValue(k)),
+ inline: ["margin", "padding", "max-width"].map((k) =>
+ document.body.style.getPropertyValue(k),
+ ),
};
});
- const ZERO = ['0px', '0px', '0px', '0px'];
+ const ZERO = ["0px", "0px", "0px", "0px"];
- test('checkbox is unchecked by default', async ({ page }) => {
+ 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 }) => {
+ 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']);
+ expect(m.maxWidth).toBe("none");
+ expect(m.inline).toEqual(["0px", "0px", "none"]);
- const box = (await page.locator('body').boundingBox())!;
+ const box = (await page.locator("body").boundingBox())!;
expect(box.x).toBe(0);
- const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+ 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 }) => {
+ 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;
+ 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;
+ const wAfter = (await page.locator("#regex-editor").boundingBox())!.width;
expect(wAfter).toBeGreaterThanOrEqual(wBefore);
});
- test('fullscreen survives tab switching, add/rename and typing', async ({ page }) => {
+ 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 setDoc(page, topEditor(page), ["/x/"]);
await tabLabel(page, 0).dblclick();
- await page.locator('#tab-bar input').fill('FS');
- await page.keyboard.press('Enter');
+ await page.locator("#tab-bar input").fill("FS");
+ await page.keyboard.press("Enter");
const m = await bodyMetrics(page);
expect(m.margin).toEqual(ZERO);
@@ -535,7 +640,7 @@ test.describe('fullscreen', () => {
await expect(fullscreen(page)).toBeChecked();
});
- test('toggling repeatedly is stable', async ({ page }) => {
+ test("toggling repeatedly is stable", async ({ page }) => {
const before = await bodyMetrics(page);
for (let i = 0; i < 3; i++) {
await fullscreen(page).check();
@@ -550,21 +655,25 @@ test.describe('fullscreen', () => {
// 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', () => {
+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');
+ 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');
+ await page.keyboard.press("ControlOrMeta+End");
const len1 = (await docText(mainEditor(page))).length;
expect((await selection(mainEditor(page))).head).toBe(len1);
@@ -573,23 +682,25 @@ test.describe('per-tab editor state', () => {
// 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 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"
+ 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');
+ await page.keyboard.press("ControlOrMeta+a");
const all1 = await selection(mainEditor(page));
expect(all1.anchor).toBe(0);
expect(all1.head).toBeGreaterThan(0);
@@ -602,14 +713,16 @@ test.describe('per-tab editor state', () => {
// 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');
+ 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
+ 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);
@@ -618,76 +731,80 @@ test.describe('per-tab editor state', () => {
expect(await selection(mainEditor(page))).toEqual(sel);
});
- test('undo history is preserved per tab and does not leak between tabs', async ({ page }) => {
+ 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 setDoc(page, mainEditor(page), ["base"]);
await page.waitForTimeout(HISTORY_GAP);
- await page.keyboard.type(' one');
+ await page.keyboard.type(" one");
await page.waitForTimeout(HISTORY_GAP);
- await page.keyboard.type(' two');
+ 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');
+ 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.keyboard.press("ControlOrMeta+End");
+ await page.keyboard.type(" T1");
await page.waitForTimeout(HISTORY_GAP);
- expect(await docText(mainEditor(page))).toBe(tab1Original + ' T1');
+ 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');
+ 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');
+ await page.keyboard.press("ControlOrMeta+z");
expect(await docText(mainEditor(page))).toBe(tab1Original);
- await page.keyboard.press('ControlOrMeta+z'); // nothing more to undo
+ 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');
+ expect(await docText(mainEditor(page))).toBe("base one");
});
- test('redo history is preserved across tab switches', async ({ page }) => {
- await setDoc(page, mainEditor(page), ['alpha']);
+ 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.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 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.keyboard.type("noise");
await page.waitForTimeout(HISTORY_GAP);
await switchTo(page, 0);
- expect(await docText(mainEditor(page))).toBe('alpha');
+ expect(await docText(mainEditor(page))).toBe("alpha");
await focusEditor(mainEditor(page));
await redo(page);
- expect(await docText(mainEditor(page))).toBe('alpha beta');
+ expect(await docText(mainEditor(page))).toBe("alpha beta");
await redo(page);
- expect(await docText(mainEditor(page))).toBe('alpha beta gamma');
+ 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']);
+ 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');
+ await page.keyboard.type("678");
+ await page.keyboard.press("Home");
const cursor = await selection(mainEditor(page));
await switchTo(page, 1);
@@ -695,30 +812,32 @@ test.describe('per-tab editor state', () => {
expect(await selection(mainEditor(page))).toEqual(cursor);
await focusEditor(mainEditor(page));
- await page.keyboard.press('ControlOrMeta+z');
- expect(await docText(mainEditor(page))).toBe('12345');
+ 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']);
+ 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');
+ 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');
+ 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');
+ 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++) {
@@ -730,24 +849,28 @@ test.describe('per-tab editor state', () => {
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']);
+ 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);
+ 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 setDoc(page, topEditor(page), ["/qqq/"]);
await switchTo(page, 0);
- await expect.poll(() => matchTexts(page, 0)).toEqual(['qqq']);
+ 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/']);
+ 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/');
+ expect(await docText(topEditor(page))).toBe("/keep/\n/me/");
});
});