From 976c0d950461ab884a9d107659d3ba103d8cb269 Mon Sep 17 00:00:00 2001 From: Hizbu Azka Date: Wed, 21 Oct 2026 14:31:59 +0700 Subject: selfhost cm, add dark theme --- _tools/cm-esbuild/package-lock.json | 650 +++++++++++++++++++++++++++++ _tools/cm-esbuild/package.json | 26 ++ _tools/cm-esbuild/scripts/deploy.js | 37 ++ _tools/cm-esbuild/src/commands.js | 1 + _tools/cm-esbuild/src/language.js | 1 + _tools/cm-esbuild/src/state.js | 1 + _tools/cm-esbuild/src/theme.js | 1 + _tools/cm-esbuild/src/view.js | 1 + _tools/e2e/package-lock.json | 78 ++++ _tools/e2e/package.json | 13 + _tools/e2e/playwright.config.ts | 80 ++++ _tools/e2e/tests/regex-editor.spec.ts | 753 ++++++++++++++++++++++++++++++++++ 12 files changed, 1642 insertions(+) create mode 100644 _tools/cm-esbuild/package-lock.json create mode 100644 _tools/cm-esbuild/package.json create mode 100644 _tools/cm-esbuild/scripts/deploy.js create mode 100644 _tools/cm-esbuild/src/commands.js create mode 100644 _tools/cm-esbuild/src/language.js create mode 100644 _tools/cm-esbuild/src/state.js create mode 100644 _tools/cm-esbuild/src/theme.js create mode 100644 _tools/cm-esbuild/src/view.js create mode 100644 _tools/e2e/package-lock.json create mode 100644 _tools/e2e/package.json create mode 100644 _tools/e2e/playwright.config.ts create mode 100644 _tools/e2e/tests/regex-editor.spec.ts (limited to '_tools') 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 '); 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 => ``) + .join('\n'); + +const html = `${preload}\n\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 => + 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/'); + }); +}); -- cgit