Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1ca2bae
feat(ios, android): maxLength
hejsztynx Aug 7, 2026
19e8695
docs: tweak docs maxlength content
hejsztynx Aug 7, 2026
e88636c
refactor: simplify the default value flow
hejsztynx Aug 7, 2026
f93c3c9
refactor(ios): comment clean up
hejsztynx Aug 7, 2026
e678e79
refactor(android): stale mention check logic refinement
hejsztynx Aug 7, 2026
a8e488e
fix(ios, android): startMention respects maxLength
hejsztynx Aug 7, 2026
a374952
feat(web): maxLength
hejsztynx Aug 7, 2026
45bce5b
feat(web): e2e maxLength tests
hejsztynx Aug 7, 2026
54f77f9
fix(ios): properly truncate links
hejsztynx Aug 7, 2026
b7ebba9
feat(ios, android): mobile e2e tests
hejsztynx Aug 7, 2026
4ad5356
refactor: revert e2e local setup
hejsztynx Aug 10, 2026
88241a5
fix(web): undeterministic e2e test suite
hejsztynx Aug 10, 2026
927f285
Merge branch 'main' into @ksienkiewicz/feat-max-length
hejsztynx Aug 10, 2026
7fbb5b9
refactor: operator cleanup and grammar fixes
hejsztynx Aug 10, 2026
4785fc0
Merge branch 'main' into @ksienkiewicz/feat-max-length
hejsztynx Aug 13, 2026
5856dda
docs: update docs
hejsztynx Aug 13, 2026
4e5f0b0
Revert "docs: update docs"
hejsztynx Aug 19, 2026
5e939ff
Merge branch 'main' into @ksienkiewicz/feat-max-length
hejsztynx Aug 19, 2026
1876c41
fix: main merge
hejsztynx Aug 19, 2026
4e6f4be
docs: update docs
hejsztynx Aug 13, 2026
92fc683
Merge branch 'main' into @ksienkiewicz/feat-max-length
hejsztynx Aug 28, 2026
240a1d8
fix: negative maxlength values handling
hejsztynx Aug 28, 2026
9839117
fix: snap inward on composite characters
hejsztynx Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .maestro/enrichedInput/flows/max_length.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
appId: swmansion.enriched.example
---
# Test maxLength behavior: typing, native copy/paste, and HTML imperative setting
- launchApp

- tapOn:
id: 'toggle-screen-button'

# 1. Press the button to set maxLength to 10
- tapOn:
id: 'max-length-10-button'

- tapOn:
id: 'editor-input'

# 2. Write 1234567890, try to write more. The extra '123' should be rejected.
- inputText: '1234567890'
- inputText: '123'

- runFlow:
file: '../subflows/capture_or_assert_screenshot.yaml'
env:
SCREENSHOT_NAME: 'maxlength_typing_limit'

# 3. Remove 4 characters to have '123456', select it, copy, and paste at the end.
- tapOn:
id: 'editor-input'
point: '50%, 50%'

- pressKey: backspace
- pressKey: backspace
- pressKey: backspace
- pressKey: backspace

- doubleTapOn:
id: 'editor-input'
point: '10%, 30%'

- tapOn:
text: 'Copy'

- tapOn:
id: 'editor-input'
point: '50%, 30%'

- longPressOn:
id: 'editor-input'
point: '50%, 30%'

- tapOn:
text: 'Paste'

- runFlow:
file: '../subflows/capture_or_assert_screenshot.yaml'
env:
SCREENSHOT_NAME: 'maxlength_paste_limit'

# 4. Set inputs value as `123456 <a>some longer link</a>`, style should be present on remaining text
- runFlow:
file: '../subflows/set_editor_value.yaml'
env:
VALUE: '<p>123456 <a href="https://swmansion.com">some longer link</a></p>'

- runFlow:
file: '../subflows/capture_or_assert_screenshot.yaml'
env:
SCREENSHOT_NAME: 'maxlength_link_kept'

# 5. Set inputs value as `123456 <mention>John Doe</mention>`, style should be stripped
- runFlow:
file: '../subflows/set_editor_value.yaml'
env:
VALUE: '<p>12345 <mention text="@John Doe" indicator="@">@John Doe</mention></p>'

- runFlow:
file: '../subflows/capture_or_assert_screenshot.yaml'
env:
SCREENSHOT_NAME: 'maxlength_mention_stripped'
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
287 changes: 287 additions & 0 deletions .playwright/tests/maxLength.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import { test, expect, type Page } from '@playwright/test';

import { copySelectionFrom } from '../helpers/clipboard';

async function pasteAtCurrentSelection(page: Page): Promise<void> {
await page.keyboard.press('ControlOrMeta+V');
}

test.setTimeout(90_000);

const sel = {
editorInner: '[data-testid="test-max-length-editor"] .eti-editor',
maxLengthInput: '[data-testid="test-max-length-maxlength-input"]',
htmlInput: '[data-testid="test-max-length-html-input"]',
setValueButton: '[data-testid="test-max-length-set-value-button"]',
htmlOutput: '[data-testid="test-max-length-html-output"]',
selectionStart: '[data-testid="test-max-length-selection-start"]',
selectionEnd: '[data-testid="test-max-length-selection-end"]',
applySelection: '[data-testid="test-max-length-apply-selection-button"]',
setLinkStart: '[data-testid="test-max-length-setlink-start"]',
setLinkEnd: '[data-testid="test-max-length-setlink-end"]',
setLinkText: '[data-testid="test-max-length-setlink-text"]',
setLinkUrl: '[data-testid="test-max-length-setlink-url"]',
applySetLink: '[data-testid="test-max-length-apply-setlink-button"]',
mentionIndicator: '[data-testid="test-max-length-mention-indicator"]',
mentionText: '[data-testid="test-max-length-mention-text"]',
setMentionButton: '[data-testid="test-max-length-set-mention-button"]',
startMentionButton: '[data-testid="test-max-length-start-mention-button"]',
imageSrc: '[data-testid="test-max-length-image-src"]',
imageWidth: '[data-testid="test-max-length-image-width"]',
imageHeight: '[data-testid="test-max-length-image-height"]',
setImageButton: '[data-testid="test-max-length-set-image-button"]',
} as const;

async function gotoTestMaxLength(page: Page): Promise<void> {
await page.goto('/test-max-length');
await page.waitForSelector(sel.editorInner);

const routePattern = '**/pw-e2e-ok.png';
const pngBody = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
await page.route(routePattern, async (route) => {
await route.fulfill({
status: 200,
contentType: 'image/png',
body: pngBody,
});
});
}

async function getHtmlOutput(page: Page): Promise<string> {
return (await page.locator(sel.htmlOutput).textContent()) ?? '';
}

async function setValue(page: Page, html: string): Promise<void> {
await page.fill(sel.htmlInput, html);
await page.click(sel.setValueButton);
await expect
.poll(async () => {
const t = await getHtmlOutput(page);
return t.startsWith('<html>');
})
.toBe(true);
}

async function setSelection(
page: Page,
start: number,
end: number
): Promise<void> {
await page.fill(sel.selectionStart, String(start));
await page.fill(sel.selectionEnd, String(end));
await page.click(sel.applySelection);
// Clicking the button moves DOM focus away from the editor even though the
// ProseMirror selection itself is preserved; refocus so keyboard input lands.
await page.locator(`${sel.editorInner} .ProseMirror`).focus();
}

function firstParagraph(page: Page) {
return page.locator(sel.editorInner).locator('p').first();
}

async function focusEditor(page: Page) {
const editor = page.locator(sel.editorInner);
await firstParagraph(page).click();
await expect(editor.locator('.ProseMirror')).toBeFocused();
return editor;
}

test.describe('test-max-length typing', () => {
test('typing stops accepting characters once maxLength is reached', async ({
page,
}) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p></p></html>');

await focusEditor(page);
await page.keyboard.type('12345678901234', { delay: 20 });

await expect
.poll(async () => getHtmlOutput(page))
.toContain('<p>1234567890</p>');
});

test('typing under maxLength is unaffected', async ({ page }) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p></p></html>');

await focusEditor(page);
await page.keyboard.type('12345', { delay: 20 });

await expect
.poll(async () => getHtmlOutput(page))
.toContain('<p>12345</p>');
});
});

test.describe('test-max-length pasting', () => {
test.use({ permissions: ['clipboard-read', 'clipboard-write'] });

test('pasting content that fits exactly is not truncated', async ({
page,
}) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p>1234567</p></html>');

await copySelectionFrom(firstParagraph(page));

await setValue(page, '<html><p></p></html>');
await focusEditor(page);
await page.keyboard.type('123', { delay: 20 });
await pasteAtCurrentSelection(page);

await expect
.poll(async () => getHtmlOutput(page))
.toContain('<p>1231234567</p>');
});

test('pasting into non-empty content truncates the pasted portion, keeping earlier text', async ({
page,
}) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p>1234567</p></html>');

await copySelectionFrom(firstParagraph(page));

await setValue(page, '<html><p>abcde</p></html>');
await firstParagraph(page).click();
await page.keyboard.press('End');
await pasteAtCurrentSelection(page);

await expect
.poll(async () => getHtmlOutput(page))
.toContain('<p>abcde12345</p>');
});
});

test.describe('test-max-length mentions', () => {
test('mention that fits entirely keeps its mark', async ({ page }) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p>123</p></html>');
await setSelection(page, 4, 4);
await page.keyboard.type(' ');
await page.click(sel.startMentionButton);

await page.fill(sel.mentionText, '@John');
await page.click(sel.setMentionButton);

await expect
.poll(async () => getHtmlOutput(page))
.toContain('<mention text="@John" indicator="@" id="1">@John</mention>');
});

test('mention overflowing maxLength is truncated and loses its mark', async ({
page,
}) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p>1234</p></html>');
await setSelection(page, 4, 4);
await page.waitForTimeout(100);
await page.keyboard.type(' ');
await page.click(sel.startMentionButton);

await page.fill(sel.mentionText, '@Jonathan');
await page.click(sel.setMentionButton);

await expect
.poll(async () => getHtmlOutput(page))
.toContain('<p>1234 @Jona</p>');
await expect
.poll(async () => getHtmlOutput(page))
.not.toContain('<mention');
});
});

test.describe('test-max-length links', () => {
test('manual link that fits entirely keeps its mark', async ({ page }) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p>123456</p></html>');

await page.fill(sel.setLinkStart, '6');
await page.fill(sel.setLinkEnd, '6');
await page.fill(sel.setLinkText, 'ab');
await page.fill(sel.setLinkUrl, 'https://example.com');
await page.click(sel.applySetLink);

await expect
.poll(async () => getHtmlOutput(page))
.toContain('<p>123456<a href="https://example.com">ab</a></p>');
});

test('manual link overflowing maxLength is truncated but keeps link styling', async ({
page,
}) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p>123456</p></html>');

await page.fill(sel.setLinkStart, '6');
await page.fill(sel.setLinkEnd, '6');
await page.fill(sel.setLinkText, 'abcdefgh');
await page.fill(sel.setLinkUrl, 'https://example.com');
await page.click(sel.applySetLink);

await expect
.poll(async () => getHtmlOutput(page))
.toContain('<a href="https://example.com">abcd</a>');
await expect.poll(async () => getHtmlOutput(page)).not.toContain('efgh');
});
});

test.describe('test-max-length images', () => {
test('image that fits within maxLength is inserted', async ({ page }) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p>123456789</p></html>');

await page.fill(sel.imageSrc, '/pw-e2e-ok.png');
await page.fill(sel.imageWidth, '40');
await page.fill(sel.imageHeight, '40');
await page.click(sel.setImageButton);

await expect
.poll(async () => getHtmlOutput(page))
.toContain('<img src="/pw-e2e-ok.png"');
});

test('image that would overflow maxLength is rejected entirely', async ({
page,
}) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p>1234567890</p></html>');

await page.fill(sel.imageSrc, '/pw-e2e-ok.png');
await page.fill(sel.imageWidth, '40');
await page.fill(sel.imageHeight, '40');
await page.click(sel.setImageButton);

await page.waitForTimeout(200);
await expect.poll(async () => getHtmlOutput(page)).not.toContain('<img');
await expect
.poll(async () => getHtmlOutput(page))
.toContain('<p>1234567890</p>');
});
});

test.describe('test-max-length replacing a selection with longer content', () => {
test('pasting over a selection that would grow the doc past maxLength truncates the paste', async ({
page,
}) => {
await gotoTestMaxLength(page);
await setValue(page, '<html><p>overflowing-clip-source</p></html>');

await copySelectionFrom(firstParagraph(page));

await setValue(page, '<html><p>123xxx789</p></html>');
// Select the "xxx" in the middle (positions 3..6).
await setSelection(page, 3, 6);

await pasteAtCurrentSelection(page);

await expect.poll(async () => getHtmlOutput(page)).toContain('<p>123');
const html = await getHtmlOutput(page);
expect(html).toContain('789</p>');
expect(html).not.toContain('overflowing-clip-source');
});
});
Loading
Loading