Use `update.state.sliceDoc()` rather than `update.state.doc.toJSON()`

`update.state.sliceDoc()` seems to work with EditorState.lineSeparator.
Which is better then using `update.state.doc.toJSON()` and hacking it
to apply our set line editor.

In a future change we should add a button to be able to switch between
\r\n and \n and vice versa.

Also fix the ability to run tests and fix tests.

Bug: Issue 393149327
Change-Id: I8ccfbe714c347694f80dede48462bb3a0e2655fa
diff --git a/.gitignore b/.gitignore
index bc39d2c..9fef988 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
 /bazel-*
 .tmp
 .vscode
+.DS_Store
+*.swp
\ No newline at end of file
diff --git a/web/BUILD b/web/BUILD
index 1879dae..1195c5d 100644
--- a/web/BUILD
+++ b/web/BUILD
@@ -1,6 +1,7 @@
 load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
 load("@com_googlesource_gerrit_bazlets//js:defs.bzl", "gerrit_js_bundle")
 load("//tools/js:eslint.bzl", "plugin_eslint")
+load("//tools/bzl:js.bzl", "web_test_runner")
 
 package_group(
     name = "visibility",
@@ -54,6 +55,18 @@
     ],
 )
 
+ts_project(
+    name = "codemirror-editor-ts-tests",
+    srcs = glob(["**/*.ts"]),
+    incremental = True,
+    out_dir = "_bazel_ts_out_tests",
+    tsconfig = ":tsconfig",
+    deps = [
+        "//plugins:node_modules",
+        "//polygerrit-ui:node_modules",
+    ],
+)
+
 gerrit_js_bundle(
     name = "codemirror_element",
     srcs = [":codemirror-element-ts"],
@@ -74,3 +87,15 @@
 
 # Run the lint tests with `bazel test plugins/codemirror-editor/web:lint_test`.
 plugin_eslint()
+
+# Run the lint tests with `bazel test plugins/codemirror-editor/web:web_test_runner`.
+web_test_runner(
+    name = "web_test_runner",
+    srcs = ["web_test_runner.sh"],
+    data = [
+        ":codemirror-editor-ts-tests",
+        ":tsconfig",
+        "//plugins:node_modules",
+        "//polygerrit-ui:node_modules",
+    ],
+)
\ No newline at end of file
diff --git a/web/element/codemirror-element.ts b/web/element/codemirror-element.ts
index eb014ac..ea59615 100644
--- a/web/element/codemirror-element.ts
+++ b/web/element/codemirror-element.ts
@@ -60,7 +60,8 @@
   @query('#result')
   result!: HTMLElement;
 
-  private lineTerminator?: string;
+  // Expose the editor instance so tests can dispatch editor changes.
+  editor?: EditorView;
 
   private initialized = false;
 
@@ -124,8 +125,6 @@
     if (this.initialized) return;
     this.initialized = true;
 
-    this.lineTerminator = this.fileContent?.includes('\r\n') ? '\r\n' : '\n';
-
     const editor = new EditorView({
       state: EditorState.create({
         doc: this.fileContent ?? '',
@@ -135,7 +134,7 @@
             this.prefs,
             this.fileType,
             this.fileContent ?? '',
-            this.darkMode
+            this.darkMode,
           ),
           EditorView.updateListener.of(update => {
             // Set ruler width to 72 for commit messages,
@@ -148,17 +147,17 @@
               updateRulerWidth(
                 this.prefs.line_length,
                 update.view.defaultCharacterWidth,
-                true
+                true,
               );
             }
 
             if (update.docChanged) {
               this.dispatchEvent(
                 new CustomEvent('content-change', {
-                  detail: {value: update.state.doc.toJSON().join(this.lineTerminator)},
+                  detail: {value: update.state.sliceDoc()},
                   bubbles: true,
                   composed: true,
-                })
+                }),
               );
             }
           }),
@@ -189,6 +188,7 @@
       parent: this.wrapper as Element,
     });
 
+    this.editor = editor;
     editor.focus();
 
     if (this.lineNum) {
@@ -209,6 +209,8 @@
       window.removeEventListener('resize', this.onResize);
       this.onResize = null;
     }
+    // Clear the editor reference when disconnected.
+    this.editor = undefined;
   }
 
   setCursorToLine(view: EditorView, lineNum: number) {
@@ -217,14 +219,16 @@
     // codemirror will error out. Instead lets just log.
     // Line 1 will be selected automatically.
     if (lineNum < 1 || lineNum > totalLines) {
-      console.warn(`Line number ${lineNum} is out of bounds (valid range: 1 - ${totalLines}).`);
+      console.warn(
+        `Line number ${lineNum} is out of bounds (valid range: 1 - ${totalLines}).`,
+      );
       return;
     }
 
     const line = view.state.doc.line(lineNum);
     view.dispatch({
-      selection: { anchor: line.from },
-      scrollIntoView: true
+      selection: {anchor: line.from},
+      scrollIntoView: true,
     });
     view.focus();
   }
@@ -233,7 +237,9 @@
     const cursor = view.state.selection.main.head;
     const line = view.state.doc.lineAt(cursor);
     if (this.result) {
-      this.result.textContent = `Line: ${line.number}, Column: ${cursor - line.from + 1}`;
+      this.result.textContent = `Line: ${line.number}, Column: ${
+        cursor - line.from + 1
+      }`;
     }
   }
 
diff --git a/web/element/codemirror-element_test.ts b/web/element/codemirror-element_test.ts
index 86f3f4d..4aa1fd7 100644
--- a/web/element/codemirror-element_test.ts
+++ b/web/element/codemirror-element_test.ts
@@ -3,16 +3,17 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../test/test-setup';
 import './codemirror-element';
 import {assert, fixture, html} from '@open-wc/testing';
 import {CodeMirrorElement} from './codemirror-element';
 
-suite('gr-editor tests', () => {
+suite('codemirror-element tests', () => {
   let element: CodeMirrorElement;
 
   setup(async () => {
     element = await fixture<CodeMirrorElement>(
-      html`<codemirror-element></codemirror-element>`
+      html`<codemirror-element></codemirror-element>`,
     );
     element.lineNum = 123;
     element.prefs = {
@@ -21,72 +22,193 @@
     await element.updateComplete;
   });
 
-  teardown(() => {});
-
   test('render', () => {
     assert.shadowDom.equal(
       element,
       /* HTML */ `
         <div id="wrapper">
-          <div class="CodeMirror cm-s-default" translate="no">
-            <div>
-              <textarea
-                autocapitalize="off"
-                autocorrect="off"
-                spellcheck="false"
-                tabindex="0"
+          <div class="cm-editor ͼ1 ͼ1f ͼ1g ͼ1h ͼ1i ͼ2 ͼ4">
+            <div aria-live="polite" class="cm-announced"></div>
+            <div class="cm-scroller" tabindex="-1">
+              <div
+                aria-hidden="true"
+                class="cm-gutters cm-gutters-before"
+                style="min-height: 14px; position: sticky;"
               >
-              </textarea>
-            </div>
-            <div
-              class="CodeMirror-vscrollbar"
-              cm-not-content="true"
-              tabindex="-1"
-            >
-              <div></div>
-            </div>
-            <div
-              class="CodeMirror-hscrollbar"
-              cm-not-content="true"
-              tabindex="-1"
-            >
-              <div></div>
-            </div>
-            <div
-              class="CodeMirror-scrollbar-filler"
-              cm-not-content="true"
-            ></div>
-            <div class="CodeMirror-gutter-filler" cm-not-content="true"></div>
-            <div class="CodeMirror-scroll" tabindex="-1">
-              <div class="CodeMirror-sizer">
-                <div>
-                  <div class="CodeMirror-lines" role="presentation">
-                    <div role="presentation">
-                      <div class="CodeMirror-measure">
-                        <pre class="CodeMirror-line-like"></pre>
-                      </div>
-                      <div class="CodeMirror-measure"></div>
-                      <div></div>
-                      <div class="CodeMirror-cursors">
-                        <div class="CodeMirror-cursor"></div>
-                      </div>
-                      <div class="CodeMirror-code" role="presentation">
-                        <pre class="CodeMirror-line" role="presentation"></pre>
-                      </div>
-                    </div>
+                <div class="cm-gutter cm-lineNumbers">
+                  <div
+                    class="cm-gutterElement"
+                    style="height: 0px; visibility: hidden; pointer-events: none;"
+                  >
+                    9
+                  </div>
+                  <div
+                    class="cm-activeLineGutter cm-gutterElement"
+                    style="height: 14px;"
+                  >
+                    1
                   </div>
                 </div>
+                <div class="cm-foldGutter cm-gutter">
+                  <div
+                    class="cm-gutterElement"
+                    style="height: 0px; visibility: hidden; pointer-events: none;"
+                  >
+                    <span title="Unfold line"> › </span>
+                  </div>
+                  <div
+                    class="cm-activeLineGutter cm-gutterElement"
+                    style="height: 14px;"
+                  ></div>
+                </div>
               </div>
-              <div></div>
-              <div class="CodeMirror-gutters"></div>
+              <div
+                aria-multiline="true"
+                autocapitalize="off"
+                autocorrect="off"
+                class="cm-content"
+                contenteditable="true"
+                role="textbox"
+                spellcheck="false"
+                style="tab-size: 0;"
+                translate="no"
+                writingsuggestions="false"
+              >
+                <div class="cm-line">
+                  <br />
+                </div>
+              </div>
+              <div
+                aria-hidden="true"
+                class="cm-cursorLayer cm-layer cm-layer-above"
+                style="z-index: 150; animation-duration: 1200ms;"
+              ></div>
+              <div
+                aria-hidden="true"
+                class="cm-layer cm-selectionLayer"
+                style="z-index: -2;"
+              ></div>
             </div>
           </div>
         </div>
+        <div class="statusLine">
+          <div class="cursorPosition">
+            <span id="result"> Line: 1, Column: 1 </span>
+          </div>
+        </div>
       `,
-      {
-        ignoreTags: ['span'],
-        ignoreAttributes: ['style'],
-      }
     );
   });
+
+  async function waitForEditorInstance(
+    el: CodeMirrorElement,
+    attempts = 20,
+    delayMs = 50,
+  ) {
+    for (let i = 0; i < attempts && !el.editor; i++) {
+      await new Promise<void>(resolve => {
+        setTimeout((): void => resolve(), delayMs);
+      });
+    }
+    return el.editor;
+  }
+
+  function waitForContentChangeEvent(el: CodeMirrorElement) {
+    return new Promise<CustomEvent<{value: string}>>(resolve => {
+      el.addEventListener(
+        'content-change',
+        (e: Event): void => {
+          resolve(e as CustomEvent<{value: string}>);
+        },
+        {once: true},
+      );
+    });
+  }
+
+  test('dispatches content-change event when editor content changes', async () => {
+    const initialContent = 'first line\nsecond line\n';
+
+    const editorElement = await fixture<CodeMirrorElement>(
+      html`
+        <codemirror-element .fileContent=${initialContent}></codemirror-element>
+      `,
+    );
+
+    await editorElement.updateComplete;
+
+    const editorView = await waitForEditorInstance(editorElement);
+    assert.isOk(editorView);
+
+    const eventPromise = waitForContentChangeEvent(editorElement);
+
+    void editorView.dispatch({
+      changes: {
+        from: 0,
+        to: 0,
+        insert: 'X',
+      },
+    });
+
+    const event = await eventPromise;
+
+    assert.isString(event.detail.value);
+    assert.isTrue(event.detail.value.length > initialContent.length);
+  });
+
+  test('content-change preserves LF for LF-only inputs', async () => {
+    const editorElement = await fixture<CodeMirrorElement>(
+      html`
+        <codemirror-element .fileContent=${'a\nb\n'}></codemirror-element>
+      `,
+    );
+
+    await editorElement.updateComplete;
+
+    const editorView = await waitForEditorInstance(editorElement);
+    assert.isOk(editorView);
+
+    const eventPromise = waitForContentChangeEvent(editorElement);
+
+    void editorView.dispatch({
+      changes: {
+        from: 0,
+        to: 0,
+        insert: 'x',
+      },
+    });
+
+    const event = await eventPromise;
+
+    assert.isFalse(/\r\n/.test(event.detail.value));
+    assert.isTrue(event.detail.value.endsWith('\n'));
+  });
+
+  test('content-change preserves CRLF for CRLF inputs', async () => {
+    const editorElement = await fixture<CodeMirrorElement>(
+      html`
+        <codemirror-element .fileContent=${'a\r\nb\r\n'}></codemirror-element>
+      `,
+    );
+
+    await editorElement.updateComplete;
+
+    const editorView = await waitForEditorInstance(editorElement);
+    assert.isOk(editorView);
+
+    const eventPromise = waitForContentChangeEvent(editorElement);
+
+    void editorView.dispatch({
+      changes: {
+        from: 0,
+        to: 0,
+        insert: 'x',
+      },
+    });
+
+    const event = await eventPromise;
+
+    assert.isString(event.detail.value);
+    assert.isTrue(/\r\n/.test(event.detail.value));
+    assert.isTrue(event.detail.value.endsWith('\r\n'));
+  });
 });
diff --git a/web/element/extensions.ts b/web/element/extensions.ts
index 6c2be75..08f9c30 100644
--- a/web/element/extensions.ts
+++ b/web/element/extensions.ts
@@ -48,10 +48,10 @@
         'background-color': 'var(--background-color-secondary)',
       },
       '&.cm-editor.cm-focused': {
-        outline: 'none'
+        outline: 'none',
       },
     },
-    {dark}
+    {dark},
   );
 
 const trailingspace = () =>
@@ -144,7 +144,7 @@
   prefs?: EditPreferencesInfo,
   fileType?: string,
   fileContent?: string,
-  darkMode?: boolean
+  darkMode?: boolean,
 ) => {
   const codeExtensions: Array<Extension> = [
     lineNumbers(),
@@ -172,6 +172,10 @@
     hideTabsAndSpaces(),
   ];
 
+  if (fileContent?.includes('\r\n')) {
+    codeExtensions.push(EditorState.lineSeparator.of('\r\n'));
+  }
+
   if (!prefs) return codeExtensions;
 
   if (prefs.line_length) {
@@ -192,7 +196,7 @@
   if (prefs.syntax_highlighting && language(fileType)) {
     codeExtensions.push(
       language(fileType) as Extension,
-      syntaxHighlighting(gerritTheme, {fallback: true})
+      syntaxHighlighting(gerritTheme, {fallback: true}),
     );
   }
 
@@ -210,9 +214,5 @@
     codeExtensions.push(highlightTrailingWhitespace());
   }
 
-  if (fileContent?.includes('\r\n')) {
-    codeExtensions.push(EditorState.lineSeparator.of('\r\n'));
-  }
-
   return codeExtensions;
 };
diff --git a/web/element/ruler.ts b/web/element/ruler.ts
index 3d5bf76..1112891 100644
--- a/web/element/ruler.ts
+++ b/web/element/ruler.ts
@@ -15,7 +15,7 @@
 
     constructor(view: EditorView) {
       this.containerElement = view.dom.appendChild(
-        document.createElement('div')
+        document.createElement('div'),
       );
       this.containerElement.style.cssText = `
         position: absolute;
@@ -27,7 +27,7 @@
         overflow: hidden;
       `;
       rulerElement = this.containerElement.appendChild(
-        document.createElement('div')
+        document.createElement('div'),
       );
       rulerElement.style.cssText = `
         position: absolute;
@@ -48,7 +48,7 @@
   function updateRulerWidth(
     newWidth: number,
     defaultWidth: number,
-    force = false
+    force = false,
   ) {
     if ((newWidth !== width || force) && rulerElement) {
       width = newWidth;
diff --git a/web/eslint.config.js b/web/eslint.config.js
index 75fcd1e..ca271be 100644
--- a/web/eslint.config.js
+++ b/web/eslint.config.js
@@ -9,7 +9,7 @@
 // eslint-disable-next-line no-undef
 __plugindir = 'codemirror-editor/web';
 
-const gerritEslint = require('../../eslint.config.js');
+const gerritEslint = require('../../eslint-plugin.config.js');
 
 module.exports = defineConfig([
   {
diff --git a/web/gr-editor_test.ts b/web/gr-editor_test.ts
index 613daa4..330b92e 100644
--- a/web/gr-editor_test.ts
+++ b/web/gr-editor_test.ts
@@ -3,6 +3,7 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import './test/test-setup';
 import './gr-editor';
 import './element/codemirror-element';
 import {assert, fixture, html} from '@open-wc/testing';
@@ -40,7 +41,7 @@
   test('presence of codemirror-element', () => {
     const cmElement = queryAndAssert<HTMLElement>(
       element,
-      'codemirror-element'
+      'codemirror-element',
     );
     assert.equal(cmElement.tagName, 'CODEMIRROR-ELEMENT');
   });
@@ -48,7 +49,7 @@
   test('properties of codemirror-element', () => {
     const cmElement = queryAndAssert<CodeMirrorElement>(
       element,
-      'codemirror-element'
+      'codemirror-element',
     );
     assert.equal(cmElement.lineNum, 123);
     assert.isTrue(cmElement.prefs?.match_brackets);
diff --git a/web/plugin.ts b/web/plugin.ts
index c63b157..d1e3a8a 100644
--- a/web/plugin.ts
+++ b/web/plugin.ts
@@ -6,6 +6,6 @@
 import '@gerritcodereview/typescript-api/gerrit';
 import './gr-editor';
 
-window.Gerrit.install(plugin => {
+window.Gerrit?.install(plugin => {
   plugin.registerCustomComponent('editor', 'gr-editor', {replace: true});
 });
diff --git a/web/test/test-setup.ts b/web/test/test-setup.ts
new file mode 100644
index 0000000..3e47525
--- /dev/null
+++ b/web/test/test-setup.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '@gerritcodereview/typescript-api/gerrit';
+import {css} from 'lit';
+window.Gerrit = {
+  install: () => {},
+  styles: {
+    font: css``,
+    form: css``,
+    material: css``,
+    menuPage: css``,
+    spinner: css``,
+    subPage: css``,
+    table: css``,
+    modal: css``,
+  },
+};
diff --git a/web/test/test-util.ts b/web/test/test-util.ts
index ae43483..fcdf565 100644
--- a/web/test/test-util.ts
+++ b/web/test/test-util.ts
@@ -5,7 +5,7 @@
  */
 export function queryAll<E extends Element = Element>(
   el: Element,
-  selector: string
+  selector: string,
 ): NodeListOf<E> {
   if (!el) throw new Error('element not defined');
   const root = el.shadowRoot ?? el;
@@ -14,7 +14,7 @@
 
 export function query<E extends Element = Element>(
   el: Element | undefined,
-  selector: string
+  selector: string,
 ): E | undefined {
   if (!el) return undefined;
   const root = el.shadowRoot ?? el;
@@ -23,7 +23,7 @@
 
 export function queryAndAssert<E extends Element = Element>(
   el: Element | undefined,
-  selector: string
+  selector: string,
 ): E {
   const found = query<E>(el, selector);
   if (!found) throw new Error(`selector '${selector}' did not match anything'`);
diff --git a/web/web_test_runner.sh b/web/web_test_runner.sh
new file mode 100755
index 0000000..4c2a81b
--- /dev/null
+++ b/web/web_test_runner.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+set -euo pipefail
+./"$1" --config "$2" \
+  --dir 'plugins/codemirror-editor/web/_bazel_ts_out_tests' \
+  --test-files 'plugins/codemirror-editor/web/_bazel_ts_out_tests/*_test.js' \
+  --ts-config='plugins/codemirror-editor/web/tsconfig.json'
+