From 99fb68c12078cd178ebeb23287388ea6b5691ebf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 16:48:48 +0000 Subject: [PATCH 1/6] fix(web): normalize list markers edited in composer paragraphs --- apps/web/src/composer-block-shortcuts.test.ts | 188 +++++++++++++++++- apps/web/src/composer-rich-text-doc.ts | 114 +++++++++++ 2 files changed, 300 insertions(+), 2 deletions(-) diff --git a/apps/web/src/composer-block-shortcuts.test.ts b/apps/web/src/composer-block-shortcuts.test.ts index eea43193dd75d..5beaa9829f104 100644 --- a/apps/web/src/composer-block-shortcuts.test.ts +++ b/apps/web/src/composer-block-shortcuts.test.ts @@ -1,7 +1,9 @@ import { Editor } from "@tiptap/core"; +import { closeHistory, redo, undo } from "@tiptap/pm/history"; +import { splitBlock } from "@tiptap/pm/commands"; import { TextSelection } from "@tiptap/pm/state"; import StarterKit from "@tiptap/starter-kit"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, onTestFinished } from "vite-plus/test"; import { buildDocJson, @@ -32,7 +34,7 @@ function chord(spec: string) { } as unknown as KeyboardEvent; } -function press(value: string, spec: string) { +function createEditor(value: string) { const editor = new Editor({ extensions: [ StarterKit.configure({ @@ -53,7 +55,14 @@ function press(value: string, spec: string) { ], content: buildDocJson(value, (name) => ({ label: name, description: null })), }); + editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins })); editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + onTestFinished(() => editor.destroy()); + return editor; +} + +function press(value: string, spec: string) { + const editor = createEditor(value); const event = chord(spec); const before = editor.state.doc; const handled = editor.extensionManager.plugins.some((plugin) => @@ -92,3 +101,178 @@ describe("Tiptap's block shortcuts", () => { expect(result.value).toBe(value); }); }); + +describe("composer list normalization", () => { + it("converts a dash edited on an earlier line and keeps the view equal to Markdown", () => { + const editor = createEditor(""); + editor.view.dispatch(editor.state.tr.insertText("-get md")); + splitBlock(editor.state, editor.view.dispatch); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2))); + editor.view.dispatch(editor.state.tr.insertText(" ")); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe("get md"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("- get md\n"); + expect( + editor.state.doc.eq( + editor.schema.nodeFromJSON( + buildDocJson(serializeEditorDoc(editor.state.doc).value, (name) => ({ + label: name, + description: null, + })), + ), + ), + ).toBe(true); + }); + + it("converts a dash and space typed before existing text", () => { + const editor = createEditor("existing text"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc))); + editor.view.dispatch(editor.state.tr.insertText("-")); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + editor.view.dispatch(editor.state.tr.insertText(" ")); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(editor.state.selection.$from.parentOffset).toBe(0); + expect(serializeEditorDoc(editor.state.doc).value).toBe("- existing text"); + }); + + it.each(["- ", "* ", "+ ", "3) ", "4. ", "- [ ] ", "- [x] "])( + "converts %j inserted in front of existing text", + (marker) => { + const editor = createEditor("existing text"); + editor.view.dispatch(editor.state.tr.insertText(marker, 1)); + const list = editor.state.doc.firstChild!; + expect(list.type.name).toBe( + marker.includes("[") ? "taskList" : /^\d/.test(marker) ? "orderedList" : "bulletList", + ); + expect(list.firstChild?.textContent).toBe("existing text"); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${marker}existing text`); + }, + ); + + it.each(["a - b", "-x", "-"])("leaves %j as a paragraph", (text) => { + const editor = createEditor(""); + editor.view.dispatch(editor.state.tr.insertText(text)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.textContent).toBe(text); + }); + + it("undoes the insertion and conversion together and can redo them", () => { + const editor = createEditor("existing text"); + editor.view.dispatch(closeHistory(editor.state.tr).insertText("- ", 1)); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(undo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.textContent).toBe("existing text"); + expect(redo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + }); + + it("keeps the dash gesture available until a task marker is complete", () => { + const editor = createEditor(""); + for (const ch of "- [x]") { + editor.view.dispatch(editor.state.tr.insertText(ch)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + } + editor.view.dispatch(editor.state.tr.insertText(" ")); + expect(editor.state.doc.firstChild?.type.name).toBe("taskList"); + expect(editor.state.doc.firstChild?.firstChild?.attrs.checked).toBe(true); + }); + + it("normalizes pasted markers without waiting for another keystroke", () => { + const editor = createEditor("existing text"); + editor.view.dispatch(editor.state.tr.insertText("- ", 1).setMeta("uiEvent", "paste")); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + }); + + it.each(["```\ncode\n```", "- outer\n - inner", "> quoted"])( + "does not convert paragraphs inside %j", + (value) => { + const editor = createEditor(value); + let position = 0; + editor.state.doc.descendants((node, pos) => { + if (node.isText) position = pos; + }); + const before: string[] = []; + editor.state.doc.descendants((node) => { + before.push(node.type.name); + }); + editor.view.dispatch(editor.state.tr.insertText("- ", position)); + const after: string[] = []; + editor.state.doc.descendants((node) => { + after.push(node.type.name); + }); + expect(after).toEqual(before); + expect(editor.state.doc.textContent).toContain("- "); + }, + ); + + it("waits until composition ends before changing paragraph structure", async () => { + const editor = createEditor(""); + editor.view.dispatch(editor.state.tr.insertText("- text").setMeta("composition", 1)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + editor.view.dispatch(editor.state.tr.insertText("日本語", 3, 7).setMeta("composition", 1)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + const normalization = editor.state.plugins.find( + (plugin) => plugin.props.handleDOMEvents?.compositionend && plugin.spec.appendTransaction, + )!; + normalization.props.handleDOMEvents!.compositionend!.call( + normalization, + editor.view, + new Event("compositionend") as CompositionEvent, + ); + // ProseMirror flushes the final composition text in a microtask after this event. + queueMicrotask(() => { + editor.view.dispatch(editor.state.tr.insertText("確定", 3, 6).setMeta("composition", 1)); + }); + await Promise.resolve(); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + await Promise.resolve(); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("- 確定"); + expect(undo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.textContent).toBe(""); + expect(redo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + }); + + it("does not immediately reconvert a literal marker restored by undo", () => { + const editor = createEditor(""); + editor.view.dispatch(editor.state.tr.insertText("- ")); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + editor.view.dispatch(closeHistory(editor.state.tr).insertText("text")); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(undo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.textContent).toBe("- "); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc))); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + }); + + it("preserves formatting and the caret when removing the literal prefix", () => { + const editor = createEditor("**text**"); + editor.view.dispatch(editor.state.tr.insertText("- ", 1)); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + expect(editor.state.selection.$from.parentOffset).toBe(4); + expect(serializeEditorDoc(editor.state.doc).value).toBe("- **text**"); + }); + + it("does not turn an inline code marker into a list", () => { + const editor = createEditor("`-text`"); + editor.view.dispatch(editor.state.tr.insertText(" ", 2)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("`- text`"); + }); + + it("only normalizes changed blocks", () => { + const editor = createEditor(""); + editor.view.updateState(editor.state.reconfigure({ plugins: [] })); + editor.view.dispatch(editor.state.tr.insertText("- untouched")); + splitBlock(editor.state, editor.view.dispatch); + editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins })); + editor.view.dispatch(editor.state.tr.insertText("changed")); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.firstChild?.textContent).toBe("- untouched"); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 35d8b50c202b1..5978580ae6e5d 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -13,12 +13,16 @@ import { Heading } from "@tiptap/extension-heading"; import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; import { Italic, starInputRegex, starPasteRegex } from "@tiptap/extension-italic"; import { + combineTransactionSteps, + getChangedRanges, type Command, type Editor, markInputRule, markPasteRule, mergeAttributes, } from "@tiptap/core"; +import { isHistoryTransaction } from "@tiptap/pm/history"; +import { findWrapping } from "@tiptap/pm/transform"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { TaskItem } from "@tiptap/extension-task-item"; import { TaskList } from "@tiptap/extension-task-list"; @@ -300,6 +304,12 @@ const ComposerListItemExtension = ListItem.extend({ export const ComposerListExtensions = [ BulletList.extend({ + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + listNormalizationPlugin(() => this.editor.view.composing), + ]; + }, addKeyboardShortcuts() { return withoutBlockChords(this.parent?.(), ["Mod-Shift-8"]); }, @@ -440,6 +450,110 @@ function parseListPrefix(line: string): { prefix: ListLinePrefix; markerLength: return null; } +/** Only changed top-level paragraphs can acquire a literal list prefix outside an input rule. */ +function listNormalizationPlugin(isComposing: () => boolean) { + let pending = new Set(); + const key = new PluginKey("composerListNormalization"); + return new Plugin({ + key, + props: { + handleDOMEvents: { + compositionend(view) { + // ProseMirror queues its final DOM flush later in this event's compositionend handler. + queueMicrotask(() => + queueMicrotask(() => { + if (!view.isDestroyed && pending.size > 0) + view.dispatch(view.state.tr.setMeta(key, true)); + }), + ); + return false; + }, + }, + }, + appendTransaction(transactions, oldState, state) { + if (transactions.some(isHistoryTransaction)) { + pending.clear(); + return null; + } + const changes = combineTransactionSteps(oldState.doc, [...transactions]); + pending = new Set( + [...pending].flatMap((pos) => { + const mapped = changes.mapping.mapResult(pos, 1); + return mapped.deleted ? [] : [mapped.pos]; + }), + ); + for (const { newRange } of getChangedRanges(changes)) { + state.doc.nodesBetween(newRange.from, newRange.to, (node, pos) => { + if (node.type.name === "paragraph") pending.add(pos); + return false; + }); + } + if (isComposing() || transactions.some((tr) => tr.getMeta("composition") != null)) + return null; + + const tr = state.tr; + for (const pos of [...pending].sort((a, b) => b - a)) { + pending.delete(pos); + if (state.doc.resolve(pos).depth !== 0) continue; + const node = state.doc.nodeAt(pos); + if (node?.type.name !== "paragraph") continue; + const text = node.textBetween(0, node.content.size, undefined, "\ufffc"); + const parsed = parseListPrefix(text); + if (!parsed || parsed.prefix.indent) continue; + const { prefix, markerLength } = parsed; + const code = state.schema.marks.code; + if (code && node.rangeHasMark(0, markerLength, code)) continue; + const active = state.selection.$from.depth === 1 && state.selection.$from.before(1) === pos; + const content = text.slice(markerLength); + // Keep `- ` and unfinished task markers literal while the user is entering the gesture. + if ( + active && + (prefix.kind === "task" + ? !prefix.contentSpace + : prefix.kind === "bullet" && + prefix.marker === "-" && + (!content || content.startsWith("["))) + ) { + pending.add(pos); + continue; + } + if (prefix.kind !== "task" && !prefix.space) continue; + const listType = + state.schema.nodes[ + prefix.kind === "task" + ? "taskList" + : prefix.kind === "ordered" + ? "orderedList" + : "bulletList" + ]; + if (!listType) continue; + const attrs = + prefix.kind === "ordered" ? { start: Number.parseInt(prefix.marker, 10) || 1 } : {}; + const $from = tr.doc.resolve(pos + 1); + const range = $from.blockRange(); + const wrapping = range && findWrapping(range, listType, attrs); + if (!range || !wrapping) continue; + tr.wrap(range, wrapping); + tr.setNodeMarkup( + pos + 1, + undefined, + prefix.kind === "task" + ? { + checked: prefix.checked, + indent: prefix.indent, + markerSpace: prefix.markerSpace, + contentSpace: prefix.contentSpace, + } + : { indent: prefix.indent, marker: prefix.marker, space: prefix.space }, + ); + tr.delete(pos + 3, pos + 3 + markerLength); + } + pending = new Set([...pending].map((pos) => tr.mapping.map(pos, 1))); + return tr.docChanged ? tr : null; + }, + }); +} + function listKey(prefix: ListLinePrefix): string { if (prefix.kind === "task") return "task"; if (prefix.kind === "ordered") return `ordered:${prefix.marker.slice(-1)}`; From 0eea92865b552da884ed1da8ba0361bc068aa4e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 16:54:43 +0000 Subject: [PATCH 2/6] fix(web): normalize bracket-prefixed composer list content --- apps/web/src/composer-block-shortcuts.test.ts | 11 +++++++++++ apps/web/src/composer-rich-text-doc.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/composer-block-shortcuts.test.ts b/apps/web/src/composer-block-shortcuts.test.ts index 5beaa9829f104..39cf507fcb7c8 100644 --- a/apps/web/src/composer-block-shortcuts.test.ts +++ b/apps/web/src/composer-block-shortcuts.test.ts @@ -137,6 +137,17 @@ describe("composer list normalization", () => { expect(serializeEditorDoc(editor.state.doc).value).toBe("- existing text"); }); + it.each(["[link](url)", "[ordinary text]", "[x]text"])( + "converts a dash before existing bracket-prefixed text %j", + (text) => { + const editor = createEditor(text); + editor.view.dispatch(editor.state.tr.insertText("- ", 1)); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe(text); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`- ${text}`); + }, + ); + it.each(["- ", "* ", "+ ", "3) ", "4. ", "- [ ] ", "- [x] "])( "converts %j inserted in front of existing text", (marker) => { diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 5978580ae6e5d..94f6f84be4dc5 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -512,7 +512,7 @@ function listNormalizationPlugin(isComposing: () => boolean) { ? !prefix.contentSpace : prefix.kind === "bullet" && prefix.marker === "-" && - (!content || content.startsWith("["))) + (!content || /^\[(?:[ xX]\]?)?$/.test(content))) ) { pending.add(pos); continue; From 9c3c3acbc65b6cd756052c03ce9a41a905e40b90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 16:58:18 +0000 Subject: [PATCH 3/6] fix(web): preserve styled literal composer list markers --- apps/web/src/composer-block-shortcuts.test.ts | 36 ++++++++++--------- apps/web/src/composer-rich-text-doc.ts | 23 ++++-------- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/apps/web/src/composer-block-shortcuts.test.ts b/apps/web/src/composer-block-shortcuts.test.ts index 39cf507fcb7c8..cc85589203d98 100644 --- a/apps/web/src/composer-block-shortcuts.test.ts +++ b/apps/web/src/composer-block-shortcuts.test.ts @@ -55,6 +55,7 @@ function createEditor(value: string) { ], content: buildDocJson(value, (name) => ({ label: name, description: null })), }); + // Headless editors need the plugins installed explicitly because there is no mounted view. editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins })); editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); onTestFinished(() => editor.destroy()); @@ -219,27 +220,15 @@ describe("composer list normalization", () => { }, ); - it("waits until composition ends before changing paragraph structure", async () => { + it("waits for a non-composition transaction before changing paragraph structure", () => { const editor = createEditor(""); editor.view.dispatch(editor.state.tr.insertText("- text").setMeta("composition", 1)); expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); editor.view.dispatch(editor.state.tr.insertText("日本語", 3, 7).setMeta("composition", 1)); expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); - const normalization = editor.state.plugins.find( - (plugin) => plugin.props.handleDOMEvents?.compositionend && plugin.spec.appendTransaction, - )!; - normalization.props.handleDOMEvents!.compositionend!.call( - normalization, - editor.view, - new Event("compositionend") as CompositionEvent, - ); - // ProseMirror flushes the final composition text in a microtask after this event. - queueMicrotask(() => { - editor.view.dispatch(editor.state.tr.insertText("確定", 3, 6).setMeta("composition", 1)); - }); - await Promise.resolve(); + editor.view.dispatch(editor.state.tr.insertText("確定", 3, 6).setMeta("composition", 1)); expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); - await Promise.resolve(); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); expect(serializeEditorDoc(editor.state.doc).value).toBe("- 確定"); expect(undo(editor.state, editor.view.dispatch)).toBe(true); @@ -263,7 +252,7 @@ describe("composer list normalization", () => { it("preserves formatting and the caret when removing the literal prefix", () => { const editor = createEditor("**text**"); - editor.view.dispatch(editor.state.tr.insertText("- ", 1)); + editor.view.dispatch(editor.state.tr.insert(1, editor.schema.text("- "))); expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); expect(editor.state.selection.$from.parentOffset).toBe(4); expect(serializeEditorDoc(editor.state.doc).value).toBe("- **text**"); @@ -276,6 +265,21 @@ describe("composer list normalization", () => { expect(serializeEditorDoc(editor.state.doc).value).toBe("`- text`"); }); + it.each(["**- x**", "*-* x", "~~- x~~", "a\n**1.** b"])( + "preserves styled markers when restoring %j", + (value) => { + const editor = createEditor(""); + editor.commands.setContent( + buildDocJson(value, (name) => ({ label: name, description: null })), + { emitUpdate: false }, + ); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value); + expect(editor.state.doc.lastChild?.type.name).toBe("paragraph"); + editor.view.dispatch(editor.state.tr.insertText("x", editor.state.doc.content.size - 1)); + expect(editor.state.doc.lastChild?.type.name).toBe("paragraph"); + }, + ); + it("only normalizes changed blocks", () => { const editor = createEditor(""); editor.view.updateState(editor.state.reconfigure({ plugins: [] })); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 94f6f84be4dc5..7b9c192359cf6 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -456,25 +456,12 @@ function listNormalizationPlugin(isComposing: () => boolean) { const key = new PluginKey("composerListNormalization"); return new Plugin({ key, - props: { - handleDOMEvents: { - compositionend(view) { - // ProseMirror queues its final DOM flush later in this event's compositionend handler. - queueMicrotask(() => - queueMicrotask(() => { - if (!view.isDestroyed && pending.size > 0) - view.dispatch(view.state.tr.setMeta(key, true)); - }), - ); - return false; - }, - }, - }, appendTransaction(transactions, oldState, state) { if (transactions.some(isHistoryTransaction)) { pending.clear(); return null; } + if (pending.size === 0 && !transactions.some((tr) => tr.docChanged)) return null; const changes = combineTransactionSteps(oldState.doc, [...transactions]); pending = new Set( [...pending].flatMap((pos) => { @@ -488,6 +475,7 @@ function listNormalizationPlugin(isComposing: () => boolean) { return false; }); } + // The next non-composition transaction can normalize after the browser finishes its DOM edits. if (isComposing() || transactions.some((tr) => tr.getMeta("composition") != null)) return null; @@ -499,10 +487,13 @@ function listNormalizationPlugin(isComposing: () => boolean) { if (node?.type.name !== "paragraph") continue; const text = node.textBetween(0, node.content.size, undefined, "\ufffc"); const parsed = parseListPrefix(text); + // Input rules only introduce unindented top-level lists; keep the same boundary here. if (!parsed || parsed.prefix.indent) continue; const { prefix, markerLength } = parsed; - const code = state.schema.marks.code; - if (code && node.rangeHasMark(0, markerLength, code)) continue; + if ( + Object.values(state.schema.marks).some((mark) => node.rangeHasMark(0, markerLength, mark)) + ) + continue; const active = state.selection.$from.depth === 1 && state.selection.$from.before(1) === pos; const content = text.slice(markerLength); // Keep `- ` and unfinished task markers literal while the user is entering the gesture. From d339b7935816c9ca619b57c9ebf007894f7ac8af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 12:30:38 +0000 Subject: [PATCH 4/6] test(web): add composer list-marker evidence driver for #22989 Co-Authored-By: Claude Opus 5.5 --- .../evidence/drive-composer-list-markers.mjs | 684 ++++++++++++++++++ 1 file changed, 684 insertions(+) create mode 100644 scripts/evidence/drive-composer-list-markers.mjs diff --git a/scripts/evidence/drive-composer-list-markers.mjs b/scripts/evidence/drive-composer-list-markers.mjs new file mode 100644 index 0000000000000..e292a3b7561be --- /dev/null +++ b/scripts/evidence/drive-composer-list-markers.mjs @@ -0,0 +1,684 @@ +#!/usr/bin/env node +// Drives the rich-text chat composer through the list-marker fixtures of PR #22997 +// (late-completed Markdown list markers) and records a self-narrating video plus a +// per-step log, so the same keystrokes can be compared on two revisions. +// +// Run against a dev server that is already paired (see `vp run dev` and its pairingUrl): +// +// PLAYWRIGHT_MODULE=/node_modules/.pnpm/playwright@1.60.0/node_modules/playwright/index.mjs \ +// node drive-composer-list-markers.mjs \ +// --base-url http://localhost:5733 --label after --viewport 1440 --theme light \ +// --rows F1,F2,F6 --storage-state ./state-after.json --out-dir ./out [--pair-url ] \ +// [--stills F1,F14] [--no-video] +// +// --pair-url pairs a fresh browser and writes --storage-state; later runs reuse it. +// Outputs, per run: