summaryrefslogtreecommitdiff
path: root/_tools
diff options
context:
space:
mode:
Diffstat (limited to '_tools')
-rw-r--r--_tools/cm-esbuild/package-lock.json650
-rw-r--r--_tools/cm-esbuild/package.json26
-rw-r--r--_tools/cm-esbuild/scripts/deploy.js37
-rw-r--r--_tools/cm-esbuild/src/commands.js1
-rw-r--r--_tools/cm-esbuild/src/language.js1
-rw-r--r--_tools/cm-esbuild/src/state.js1
-rw-r--r--_tools/cm-esbuild/src/theme.js1
-rw-r--r--_tools/cm-esbuild/src/view.js1
-rw-r--r--_tools/e2e/package-lock.json78
-rw-r--r--_tools/e2e/package.json13
-rw-r--r--_tools/e2e/playwright.config.ts80
-rw-r--r--_tools/e2e/tests/regex-editor.spec.ts753
12 files changed, 1642 insertions, 0 deletions
diff --git a/_tools/cm-esbuild/package-lock.json b/_tools/cm-esbuild/package-lock.json
new file mode 100644
index 0000000..73ea867
--- /dev/null
+++ b/_tools/cm-esbuild/package-lock.json
@@ -0,0 +1,650 @@
+{
+ "name": "cm-rollup",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "cm-rollup",
+ "version": "1.0.0",
+ "dependencies": {
+ "@codemirror/commands": "6.11.1",
+ "@codemirror/language": "6.13.1",
+ "@codemirror/state": "6.7.6",
+ "@codemirror/view": "6.43.14",
+ "@uiw/codemirror-theme-github": "4.25.12"
+ },
+ "devDependencies": {
+ "esbuild": "0.28.2"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
+ "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.11.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
+ "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.7.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.13.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
+ "integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/streamparser": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.7.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
+ "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/streamparser": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
+ "integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
+ "license": "MIT"
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.43.14",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
+ "integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.7.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@lezer/common": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
+ "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
+ "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.11",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
+ "integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
+ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
+ "license": "MIT"
+ },
+ "node_modules/@uiw/codemirror-theme-github": {
+ "version": "4.25.12",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.25.12.tgz",
+ "integrity": "sha512-ZEdyK7WEmV91HwRs6Twgae63GtE1C2QfS40NlnRvs6vHHTQjyz+wI1Hkpf1uTpWxtrNVUrc35k9KUUXWTmViFg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.6",
+ "@lezer/highlight": "^1.0.0",
+ "@uiw/codemirror-themes": "4.25.12"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ }
+ },
+ "node_modules/@uiw/codemirror-themes": {
+ "version": "4.25.12",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.25.12.tgz",
+ "integrity": "sha512-3bLX6C+gstr82SmY2FAj2TfHXnl2SnC+whRZJmRsv82wMhG8RZlZVy+o0rLuGRIPD9acuY0gpouHVRNUnzBj3g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@codemirror/language": ">=6.0.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0"
+ }
+ },
+ "node_modules/crelt": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/style-mod": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
+ "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
+ "license": "MIT"
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/_tools/cm-esbuild/package.json b/_tools/cm-esbuild/package.json
new file mode 100644
index 0000000..4e6d5a8
--- /dev/null
+++ b/_tools/cm-esbuild/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "cm-esbuild",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "build": "esbuild src/state.js src/view.js src/commands.js src/language.js src/theme.js --bundle --format=esm --minify --splitting --target=es2022 --outdir=dist",
+ "deploy:cm": "npm run build && node scripts/deploy.js ../../"
+ },
+ "keywords": [],
+ "type": "commonjs",
+ "dependencies": {
+ "@codemirror/commands": "6.11.1",
+ "@codemirror/language": "6.13.1",
+ "@codemirror/state": "6.7.6",
+ "@codemirror/view": "6.43.14",
+ "@uiw/codemirror-theme-github": "4.25.12"
+ },
+ "devDependencies": {
+ "esbuild": "0.28.2"
+ },
+ "allowScripts": {
+ "esbuild@0.28.2": true
+ }
+}
diff --git a/_tools/cm-esbuild/scripts/deploy.js b/_tools/cm-esbuild/scripts/deploy.js
new file mode 100644
index 0000000..a06130e
--- /dev/null
+++ b/_tools/cm-esbuild/scripts/deploy.js
@@ -0,0 +1,37 @@
+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); }
+
+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');
+for (const f of jsFiles) h.update(fs.readFileSync(path.join(dist, f)));
+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));
+
+const entries = {
+ '@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');
+
+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
new file mode 100644
index 0000000..34031c2
--- /dev/null
+++ b/_tools/cm-esbuild/src/commands.js
@@ -0,0 +1 @@
+export * from '@codemirror/commands';
diff --git a/_tools/cm-esbuild/src/language.js b/_tools/cm-esbuild/src/language.js
new file mode 100644
index 0000000..9bfe6b6
--- /dev/null
+++ b/_tools/cm-esbuild/src/language.js
@@ -0,0 +1 @@
+export * from '@codemirror/language';
diff --git a/_tools/cm-esbuild/src/state.js b/_tools/cm-esbuild/src/state.js
new file mode 100644
index 0000000..b6ef173
--- /dev/null
+++ b/_tools/cm-esbuild/src/state.js
@@ -0,0 +1 @@
+export * from '@codemirror/state';
diff --git a/_tools/cm-esbuild/src/theme.js b/_tools/cm-esbuild/src/theme.js
new file mode 100644
index 0000000..d7b898a
--- /dev/null
+++ b/_tools/cm-esbuild/src/theme.js
@@ -0,0 +1 @@
+export * from '@uiw/codemirror-theme-github';
diff --git a/_tools/cm-esbuild/src/view.js b/_tools/cm-esbuild/src/view.js
new file mode 100644
index 0000000..6d964dc
--- /dev/null
+++ b/_tools/cm-esbuild/src/view.js
@@ -0,0 +1 @@
+export * from '@codemirror/view';
diff --git a/_tools/e2e/package-lock.json b/_tools/e2e/package-lock.json
new file mode 100644
index 0000000..8ffab32
--- /dev/null
+++ b/_tools/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/_tools/e2e/package.json b/_tools/e2e/package.json
new file mode 100644
index 0000000..369ba46
--- /dev/null
+++ b/_tools/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/_tools/e2e/playwright.config.ts b/_tools/e2e/playwright.config.ts
new file mode 100644
index 0000000..1e97f70
--- /dev/null
+++ b/_tools/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/_tools/e2e/tests/regex-editor.spec.ts b/_tools/e2e/tests/regex-editor.spec.ts
new file mode 100644
index 0000000..1f3c0d3
--- /dev/null
+++ b/_tools/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/');
+ });
+});