From 793d482366c1a88ba5fafe214f7edcabc650e32b Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:57:14 +0900 Subject: [PATCH 1/5] fix(extension): fill contenteditable descendants DOM.describeNode only reports the target's own attributes. A paragraph inside [contenteditable] has none, so fill rejected it before the live isContentEditable check. Agents snapshot the inner block of rich editors and then cannot type into it. --- .../src/tools/__tests__/fill.test.ts | 13 +++++++++ .../src/tools/__tests__/interaction.test.ts | 2 +- apps/extension/src/tools/interaction.ts | 29 +++++++++++++++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/extension/src/tools/__tests__/fill.test.ts b/apps/extension/src/tools/__tests__/fill.test.ts index 19f955030..4cb9c5917 100644 --- a/apps/extension/src/tools/__tests__/fill.test.ts +++ b/apps/extension/src/tools/__tests__/fill.test.ts @@ -396,6 +396,19 @@ describe("fill result verification", () => { expect(await h.fill(requested)).toMatchObject({ value_length: normalized.length }); }); + it("fills a contenteditable descendant that has no contenteditable attribute", async () => { + const h = await setup('

'); + let innerText = ""; + Object.defineProperty(h.element, "isContentEditable", { get: () => true }); + Object.defineProperty(h.element, "innerText", { get: () => innerText }); + h.insert.mockImplementation(async () => { + h.element.textContent = "hello"; + innerText = "hello"; + }); + expect(await h.fill("hello")).toMatchObject({ value_length: 5 }); + expect(h.insert).toHaveBeenCalledOnce(); + }); + it("does not count an editable padding break as an extra typed character", async () => { const h = await setup('
'); // These two layout/editability properties are missing in Happy DOM. diff --git a/apps/extension/src/tools/__tests__/interaction.test.ts b/apps/extension/src/tools/__tests__/interaction.test.ts index 5d04d2023..351339754 100644 --- a/apps/extension/src/tools/__tests__/interaction.test.ts +++ b/apps/extension/src/tools/__tests__/interaction.test.ts @@ -1322,7 +1322,7 @@ describe("handleFill", () => { ctx.refStore.set("e1", 100, { tabId: 4 }); const fake = makeFakeCdp({ "DOM.describeNode": () => ({ - node: { backendNodeId: 100, nodeName: "DIV", attributes: [] }, + node: { backendNodeId: 100, nodeName: "BUTTON", attributes: [] }, }), }); const res = await handleFill( diff --git a/apps/extension/src/tools/interaction.ts b/apps/extension/src/tools/interaction.ts index 0a1af3298..3e663a148 100644 --- a/apps/extension/src/tools/interaction.ts +++ b/apps/extension/src/tools/interaction.ts @@ -900,9 +900,34 @@ interface DescribedNode { attributes?: string[]; } +const NEVER_FILLABLE_TAGS = new Set([ + "BUTTON", + "SELECT", + "OPTION", + "OPTGROUP", + "SCRIPT", + "STYLE", + "LINK", + "META", + "IMG", + "VIDEO", + "AUDIO", + "CANVAS", + "IFRAME", + "OBJECT", + "EMBED", + "SVG", + "BR", + "HR", +]); + /** * Decide whether a node can receive `tool.fill`: native `` / - * ` +

old paragraph

second paragraph

+
outside text
`; + +export const acceptedTargets = [ + { + name: "line breaks and whitespace", + markup: '
', + steps: [ + ["\n", true, "\n"], + ["\n\n", true, "\n\n"], + ["a\r\nb", true, "a\nb"], + [" ", true, " "], + ], + }, + { + name: "normal whitespace style", + markup: '
', + steps: [ + [" a b ", true, " a b ", "\u00a0a b\u00a0"], + ["!", false, " a b !", "\u00a0a b !"], + ], + }, + { + name: "append to existing paragraphs", + markup: '

first

second

', + steps: [["!", false, "first\n\nsecond!"]], + }, + ...["text", "search", "tel", "url", "password"].map((type) => ({ + name: `${type} input`, + markup: ``, + steps: [ + ["你好🙂", true, "你好🙂"], + ["!", false, "你好🙂!"], + ["", false, "你好🙂!"], + ["", true, ""], + ], + })), + { + name: "email input", + markup: '', + steps: [ + ["x@y.com", true, "x@y.com"], + [".cn", false, "x@y.com.cn"], + ], + }, + { + name: "number input", + markup: '', + steps: [ + ["34", true, "34"], + ["5", false, "345"], + ["", true, ""], + ], + }, + { + name: "textarea", + markup: '', + steps: [ + ["a\r\nb", true, "a\nb"], + ["\n你好🙂", false, "a\nb\n你好🙂"], + ["", true, ""], + ], + }, + ...["", "true", "plaintext-only"].map((attribute) => ({ + name: `editable=${JSON.stringify(attribute)}`, + markup: `

old

second

`, + steps: [ + ["你好🙂", true, "你好🙂"], + ["\nnext\n", false, "你好🙂\nnext\n"], + ["", false, "你好🙂\nnext\n"], + ["", true, ""], + ["again", false, "again"], + ], + })), + ...["", "
", " ", "\n", "


"].map((initial) => ({ + name: `empty root ${JSON.stringify(initial)}`, + markup: `
${initial}
`, + steps: [ + ["hello", false, "hello"], + ["", true, ""], + ["\u200b", false, "\u200b"], + ], + })), + { + name: "preserved whitespace", + markup: '
', + steps: [ + ["hello", false, " hello"], + [" \n ", true, " \n "], + ], + }, + { + name: "native input inside editor", + markup: '
', + steps: [ + ["hello", true, "hello"], + ["!", false, "hello!"], + ], + }, + { + name: "independent root inside noneditable island", + markup: + '
before
old
after
', + steps: [ + ["hello", true, "hello"], + ["!", false, "hello!"], + ], + }, +]; + +export const rejectedTargets = [ + ...[ + '

old

', + '

', + '

', + '


', + '

old

', + '

a old b

', + '

old

', + '

old

', + '

old

', + ].map((target) => ({ + name: target, + markup: `

before

${target}

keep

`, + })), + { name: "noneditable textbox", markup: '
old
' }, + { name: "readonly input", markup: '' }, + { + name: "disabled fieldset", + markup: '
', + }, + ...["checkbox", "radio", "range", "date", "file"].map((type) => ({ + name: `${type} input`, + markup: ``, + })), +]; + +export default { + id: "fill-editor-roots", + routes: ["/fill-editor-roots"], + render() { + return page({ + title: "Fill editor roots", + body: `

Fill editor roots

${editorMarkup}

`, + script: `document.querySelector('#check').addEventListener('click', () => { + const data = { + notes: document.querySelector('#notes').value, + editor: document.querySelector('#editor').innerText, + guard: document.querySelector('#guard').value, + outside: document.querySelector('#outside').textContent, + }; + browserEval.send('fill.checked', data); + document.querySelector('#result').textContent = 'FILL-ROOTS-OK'; + });`, + }); + }, +}; diff --git a/evals/browser/lib/bsk-runner.mjs b/evals/browser/lib/bsk-runner.mjs index 25a40b5c1..4a780f8b8 100644 --- a/evals/browser/lib/bsk-runner.mjs +++ b/evals/browser/lib/bsk-runner.mjs @@ -74,6 +74,7 @@ export async function runBskSmokeTask({ runId, seed = task.seed, timeoutMs = 60_000, + executeProcess = runProcess, }) { const steps = []; const evidence = {}; @@ -87,14 +88,32 @@ export async function runBskSmokeTask({ let executionError; await mkdir(outputDirectory, { recursive: true }); - async function bsk(args, { timeout = timeoutMs } = {}) { - const execution = await runProcess(bskCommand, [...args, "--json"], { timeoutMs: timeout }); + async function bsk(args, { timeout = timeoutMs, expectError } = {}) { + const execution = await executeProcess(bskCommand, [...args, "--json"], { timeoutMs: timeout }); steps.push(execution); + if (expectError && !execution.timedOut && !execution.error && execution.exitCode > 0) { + let failure; + try { + failure = JSON.parse(execution.stderr.trim() || execution.stdout.trim()); + } catch { + throw new Error( + `bsk did not return the expected JSON error: ${renderStepError(execution)}`, + ); + } + if (failure.code !== expectError.code || failure.data?.reason !== expectError.reason) { + throw new Error( + `expected ${expectError.code}/${expectError.reason}, received ${failure.code}/${failure.data?.reason}`, + ); + } + return failure; + } if (execution.exitCode !== 0 || execution.timedOut || execution.error) { throw new Error( `bsk ${args.join(" ")} failed: ${execution.error ?? renderStepError(execution)}`, ); } + if (expectError) + throw new Error(`expected ${expectError.code}/${expectError.reason}, but bsk succeeded`); return parseJsonOutput(execution); } @@ -169,14 +188,18 @@ export async function runBskSmokeTask({ ]); break; case "fill": - result = await bsk([ - "fill", - ...targetArgs(step, variables), - "--value", - String(resolveValue(step.value, variables)), - ...session, - ...tabArgs(step, variables), - ]); + result = await bsk( + [ + "fill", + ...targetArgs(step, variables), + "--value", + String(resolveValue(step.value, variables)), + ...(step.noClear ? ["--no-clear"] : []), + ...session, + ...tabArgs(step, variables), + ], + { expectError: step.expectError }, + ); break; case "select": result = await bsk([ diff --git a/evals/browser/lib/case-loader.mjs b/evals/browser/lib/case-loader.mjs index d3b98dd5b..522741f09 100644 --- a/evals/browser/lib/case-loader.mjs +++ b/evals/browser/lib/case-loader.mjs @@ -69,7 +69,24 @@ function validateWorkflowStep(errors, step, path) { if (["click", "hover", "fill", "select"].includes(step.action) && !step.selector && !step.ref) { errors.push(`${path} requires selector or ref`); } - if (step.action === "fill") checkString(errors, step.value, `${path}.value`); + if (step.action === "fill") { + if (typeof step.value !== "string") errors.push(`${path}.value must be a string`); + if (step.noClear !== undefined && typeof step.noClear !== "boolean") { + errors.push(`${path}.noClear must be a boolean`); + } + } else if (step.noClear !== undefined || step.expectError !== undefined) { + errors.push(`${path}.noClear and expectError are only supported for fill`); + } + if (step.expectError !== undefined) { + if (!isObject(step.expectError)) errors.push(`${path}.expectError must be an object`); + else { + if (Object.keys(step.expectError).some((key) => !["code", "reason"].includes(key))) { + errors.push(`${path}.expectError only supports code and reason`); + } + checkString(errors, step.expectError.code, `${path}.expectError.code`); + checkString(errors, step.expectError.reason, `${path}.expectError.reason`); + } + } if (step.action === "select") { if (!Array.isArray(step.values) || step.values.length === 0) { errors.push(`${path}.values must be a non-empty array`); diff --git a/evals/browser/schemas/case.schema.json b/evals/browser/schemas/case.schema.json index d27c87750..6dfa5a61f 100644 --- a/evals/browser/schemas/case.schema.json +++ b/evals/browser/schemas/case.schema.json @@ -62,7 +62,23 @@ "items": { "type": "object", "required": ["action"], - "properties": { "action": { "type": "string" } }, + "properties": { + "action": { "type": "string" }, + "noClear": { + "type": "boolean", + "description": "Fill only: append instead of replacing." + }, + "expectError": { + "type": "object", + "description": "Fill only: require this structured CLI error.", + "required": ["code", "reason"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + }, "additionalProperties": true } } diff --git a/evals/browser/tests/eval.test.mjs b/evals/browser/tests/eval.test.mjs index bb8c4d728..6f75054c9 100644 --- a/evals/browser/tests/eval.test.mjs +++ b/evals/browser/tests/eval.test.mjs @@ -41,6 +41,7 @@ test("case manifests are discovered, ordered, and grouped into suites", () => { "diagnostics", "mobile-emulation", "generated-form", + "fill-editor-roots", "oopif-scrollbars", "snapshot-coordinates", ], @@ -48,16 +49,16 @@ test("case manifests are discovered, ordered, and grouped into suites", () => { assert.deepEqual(repositorySummary(cases, fixtureRegistry).suites, { core: 6, matrix: 1, - regression: 2, + regression: 3, }); }); test("repository validation links every case to a fixture and valid workflow evidence", () => { assert.deepEqual(validateRepositoryCases(cases, fixtureRegistry), []); const summary = repositorySummary(cases, fixtureRegistry); - assert.equal(summary.cases, 9); - assert.equal(summary.fixtureModules, 10); - assert.equal(summary.fixtureRoutes, 18); + assert.equal(summary.cases, 10); + assert.equal(summary.fixtureModules, 11); + assert.equal(summary.fixtureRoutes, 19); }); test("manifest validation rejects unknown operations and incomplete workflow steps", () => { @@ -240,6 +241,7 @@ test("coverage inventory contains all 28 operations and three manual lanes", () assert.deepEqual(coverage.find(({ operation }) => operation === "interact.fill").smokeCases, [ "form-controls", "generated-form", + "fill-editor-roots", ]); }); diff --git a/evals/browser/tests/fill-workflow.test.mjs b/evals/browser/tests/fill-workflow.test.mjs new file mode 100644 index 000000000..bcc11ad51 --- /dev/null +++ b/evals/browser/tests/fill-workflow.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { runBskSmokeTask } from "../lib/bsk-runner.mjs"; +import { validateCaseManifest } from "../lib/case-loader.mjs"; + +const manifest = JSON.parse( + await readFile( + new URL("../cases/regression/fill-editor-roots/fill-editor-roots.case.json", import.meta.url), + "utf8", + ), +); + +test("fill manifests permit empty/whitespace values and validate rejection options", () => { + for (const value of ["", " ", "\n"]) { + const copy = structuredClone(manifest); + copy.smoke.steps = [{ action: "fill", selector: "#editor", value, noClear: true }]; + assert.deepEqual(validateCaseManifest(copy), []); + } + for (const step of [ + { action: "fill", selector: "#editor", value: 1 }, + { action: "fill", selector: "#editor", value: "", noClear: "true" }, + { action: "fill", selector: "#editor", value: "", expectError: { code: "invalid_params" } }, + { + action: "click", + selector: "#editor", + expectError: { code: "invalid_params", reason: "target_not_fillable" }, + }, + ]) { + const copy = structuredClone(manifest); + copy.smoke.steps = [step]; + assert.notDeepEqual(validateCaseManifest(copy), []); + } +}); + +for (const mode of [ + "expected", + "wrong-code", + "wrong-reason", + "success", + "timeout", + "spawn-error", + "malformed", +]) { + test(`fill rejection workflow: ${mode}`, async () => { + const outputDirectory = await mkdtemp(join(tmpdir(), "fill-workflow-")); + const calls = []; + try { + const report = await runBskSmokeTask({ + bskCommand: "bsk", + outputDirectory, + server: { reset() {}, snapshot: () => ({ events: [] }) }, + serverInfo: { baseUrl: "http://localhost:4173" }, + runId: "fill-test", + task: { + id: "fill-test", + suite: "regression", + tags: [], + startPath: "/fill-editor-roots", + siteAssertions: [], + responseAssertions: [], + adapterAssertions: [], + smokeSteps: [ + { + action: "fill", + selector: "#editor", + value: "", + noClear: true, + expectError: { code: "invalid_params", reason: "target_not_fillable" }, + evidence: "rejected", + }, + ], + }, + executeProcess: async (_command, args) => { + calls.push(args); + let response = {}; + if (args[0] === "session" && args[1] === "start") response = { session_id: "test" }; + if (args[0] === "session" && args[1] === "stop") response = { stopped: ["test"] }; + if (args[0] !== "fill") + return { exitCode: 0, stdout: JSON.stringify(response), stderr: "" }; + assert.equal(args[args.indexOf("--value") + 1], ""); + assert.ok(args.includes("--no-clear")); + return { + exitCode: mode === "success" ? 0 : 1, + stdout: "{}", + timedOut: mode === "timeout", + error: mode === "spawn-error" ? "spawn failed" : undefined, + stderr: + mode === "malformed" + ? "not JSON" + : JSON.stringify({ + code: mode === "wrong-code" ? "cdp_failed" : "invalid_params", + data: { + reason: mode === "wrong-reason" ? "fill_failed" : "target_not_fillable", + }, + }), + }; + }, + }); + assert.equal(report.executionError === undefined, mode === "expected"); + assert.equal(report.evidence.rejected === true, mode === "expected"); + assert.equal(report.evidence.sessionStopped, true); + assert.ok(calls.some((args) => args[0] === "session" && args[1] === "stop")); + } finally { + await rm(outputDirectory, { recursive: true, force: true }); + } + }); +} diff --git a/packages/dsh-plugin-browserskill/src/tools.ts b/packages/dsh-plugin-browserskill/src/tools.ts index 5f9835ebb..dd0801f2b 100644 --- a/packages/dsh-plugin-browserskill/src/tools.ts +++ b/packages/dsh-plugin-browserskill/src/tools.ts @@ -692,7 +692,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar): defineTool({ name: "interact.fill", description: - "Fill an input / textarea / contenteditable element, clearing it first by default. " + + "Fill an editable text input, textarea, or contenteditable editor root, clearing it first by default. Internal paragraphs and spans are not supported. " + "Target is a snapshot ref (@e3) or a CSS selector.", parameters: { target: { diff --git a/packages/vom/src/__tests__/render.test.ts b/packages/vom/src/__tests__/render.test.ts index bf7ff797e..ab1461a7e 100644 --- a/packages/vom/src/__tests__/render.test.ts +++ b/packages/vom/src/__tests__/render.test.ts @@ -29,6 +29,26 @@ function coreRefs(refs: ReturnType["refs"]) { } describe("renderVom single-layer page", () => { + it.each([ + "", + "true", + "plaintext-only", + ])("exposes a contenteditable=%s root as a textbox ref", (contenteditable) => { + const out = renderVom( + scene([ + node({ + id: 1, + backendNodeId: 42, + tag: "div", + name: "Message editor", + attrs: { contenteditable }, + rect: { x: 0, y: 0, w: 200, h: 80 }, + }), + ]), + ); + expect(out.text).toContain('@e1 textbox "Message editor"'); + expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 42 }]); + }); it.each([ { checked: true, marker: " [checked]" }, { checked: false, marker: " [unchecked]" }, diff --git a/packages/vom/src/render.ts b/packages/vom/src/render.ts index 6aa16a58f..ec9fa946f 100644 --- a/packages/vom/src/render.ts +++ b/packages/vom/src/render.ts @@ -223,7 +223,9 @@ function isFocusable(node: VomNode): boolean { function isContentEditable(node: VomNode): boolean { const contentEditable = node.attrs?.contenteditable?.toLowerCase(); - return contentEditable === "true" || contentEditable === "plaintext-only"; + return ( + contentEditable === "" || contentEditable === "true" || contentEditable === "plaintext-only" + ); } const ICON_REFERENCE_MARKERS = new Set(["glyph", "icon", "icons", "symbol"]); From 977d88fbb285d9b87bb09e6d94d4e8b3920d83e3 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Tue, 6 Oct 2026 20:44:52 +0800 Subject: [PATCH 5/5] refactor(extension): read editor text through one line model Text nodes,
, and direct
lines, the layouts Chrome creates while typing, are now read with the same rules: a
that only holds a line open is not a character, collapsible whitespace at line edges is ignored, and an empty line without a
has no height. This replaces three layout-specific branches and fixes appending to an editor that ends with
, which wrote the text and then failed verification. Share the white-space collapsing check with fill preparation, and add regressions for a trailing
and source-formatted line wrappers. --- apps/extension/src/tools/interaction.ts | 54 ++++++++++++------- .../fill-editor-roots.fixture.mjs | 13 +++++ 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/apps/extension/src/tools/interaction.ts b/apps/extension/src/tools/interaction.ts index 70e842016..a1889d982 100644 --- a/apps/extension/src/tools/interaction.ts +++ b/apps/extension/src/tools/interaction.ts @@ -972,30 +972,46 @@ const FILL_EDITABLE_FUNCTION = `function() { return this.isConnected && supported && !this.readOnly && !this.matches(':disabled'); }`; -// Read plain text without counting Chrome's empty-line
as a character. -// insertText uses direct DIV line wrappers in rich editors and text nodes in -// plaintext editors. Preserve literal whitespace instead of losing it through -// innerText's CSS collapsing. Other rich structures retain their rendered text. +// In these modes Chrome types NBSP for spaces at line edges, and whitespace at +// line edges is source formatting rather than text. +const FILL_COLLAPSES_WHITESPACE = `['normal', 'nowrap'].includes(getComputedStyle(this).whiteSpace)`; + +// Typing produces text nodes,
, and direct
lines. Read that layout +// literally: innerText collapses whitespace and counts the
that only holds +// a line open. Other rich structures keep their rendered text. Normalize NBSP +// only in the read value; never rewrite the user's DOM characters. const FILL_VALUE_FUNCTION = `function() { if (this.matches('input,textarea')) return this.value; - const rendered = this.innerText; - const collapses = ['normal', 'nowrap'].includes(getComputedStyle(this).whiteSpace); - const children = [...this.childNodes]; - const lineWrappers = this.children.length > 0 && children.every((child, index) => - (index === 0 && child.nodeType === Node.TEXT_NODE) || - (child.nodeName === 'DIV' && getComputedStyle(child).display === 'block' && - [...child.children].every(el => el.nodeName === 'BR'))); + const collapses = ${FILL_COLLAPSES_WHITESPACE}; + const isInline = node => node.nodeType === Node.TEXT_NODE || node.nodeName === 'BR'; + const isLine = node => node.nodeName === 'DIV' && getComputedStyle(node).display === 'block' && + [...node.childNodes].every(isInline); + const nodes = [...this.childNodes]; let value; - if (lineWrappers) { - value = children.map(child => child.nodeType === Node.TEXT_NODE ? child.data : - child.childNodes.length === 1 && child.firstChild.nodeName === 'BR' ? '' : child.innerText).join('\\n'); - } else if (this.children.length === 0 && rendered !== '') { - value = collapses ? this.textContent.replace(/^[ \\t\\r\\n]+|[ \\t\\r\\n]+$/g, '') : this.textContent; + if (nodes.every(node => isInline(node) || isLine(node))) { + const lines = []; + let open; + for (const node of nodes) { + if (isLine(node)) { + lines.push([...node.childNodes]); + open = undefined; + } else { + if (!open) lines.push(open = []); + open.push(node); + } + } + value = lines.flatMap(line => { + let text = line.map((node, index) => node.nodeName !== 'BR' ? node.data : + index < line.length - 1 ? '\\n' : '').join(''); + if (collapses) text = text.replace(/^[ \\t\\r\\n]+|[ \\t\\r\\n]+$/g, ''); + // Without a
, an empty line has no height. + return text === '' && !line.some(node => node.nodeName === 'BR') ? [] : [text]; + }).join('\\n'); } else { + // An editor holding only an empty line, such as


, is empty. + const rendered = this.innerText; value = rendered === '\\n' && this.textContent === '' ? '' : rendered; } - // Chrome substitutes NBSP at line edges in whitespace-collapsing editors. - // Only normalize the read value; never rewrite the user's DOM characters. return collapses ? value.replace(/\\u00a0/g, ' ') : value; }`; @@ -1027,7 +1043,7 @@ const FILL_PREPARATION_FUNCTION = `function(value, clearBefore) { } } else { expected = expected.replace(/\\r\\n?/g, '\\n'); - if (['normal', 'nowrap'].includes(getComputedStyle(this).whiteSpace)) expected = expected.replace(/\\u00a0/g, ' '); + if (${FILL_COLLAPSES_WHITESPACE}) expected = expected.replace(/\\u00a0/g, ' '); } return { before, expected }; }`; diff --git a/evals/browser/cases/regression/fill-editor-roots/fill-editor-roots.fixture.mjs b/evals/browser/cases/regression/fill-editor-roots/fill-editor-roots.fixture.mjs index 34ae0862d..73eff1932 100644 --- a/evals/browser/cases/regression/fill-editor-roots/fill-editor-roots.fixture.mjs +++ b/evals/browser/cases/regression/fill-editor-roots/fill-editor-roots.fixture.mjs @@ -25,6 +25,19 @@ export const acceptedTargets = [ ["!", false, " a b !", "\u00a0a b !"], ], }, + { + name: "trailing line break", + markup: '
hello
', + steps: [ + ["!", false, "hello!", "hello!"], + ["\n", false, "hello!\n"], + ], + }, + { + name: "formatted line wrappers", + markup: '
\n
a
\n
b
\n
', + steps: [["!", false, "a\nb!"]], + }, { name: "append to existing paragraphs", markup: '

first

second

',