web components (author,
+// time, and `own` are reflected attributes; the message body lives in the
+// default slot). The old helpers looked for elements from the pre-
+// webcomponent chat UI — every selector here needed to move to the new tag.
export const getCurrentChatMessageCount = async (page: Page) => {
- return await page.locator('#chattext').locator('p').count()
+ return await page.locator('#chattext').locator('ep-chat-message').count()
}
export const getChatUserName = async (page: Page) => {
- return await page.locator('#chattext')
- .locator('p')
- .locator('b')
- .innerText()
+ return (await page.locator('#chattext')
+ .locator('ep-chat-message')
+ .first()
+ .getAttribute('author')) ?? ''
}
export const getChatMessage = async (page: Page) => {
+ // The slotted body contains the message text. textContent on the host
+ // returns the combined light-DOM children (author/time live in shadow DOM).
return (await page.locator('#chattext')
- .locator('p')
- .textContent({}))!
- .split(await getChatTime(page))[1]
+ .locator('ep-chat-message')
+ .first()
+ .textContent()) ?? ''
}
export const getChatTime = async (page: Page) => {
- return await page.locator('#chattext')
- .locator('p')
- .locator('.time')
- .innerText()
+ return (await page.locator('#chattext')
+ .locator('ep-chat-message')
+ .first()
+ .getAttribute('time')) ?? ''
}
export const sendChatMessage = async (page: Page, message: string) => {
@@ -90,7 +107,7 @@ export const sendChatMessage = async (page: Page, message: string) => {
await chatInput.fill(message)
await page.keyboard.press('Enter')
if (message === "") return
- await expect(page.locator('#chattext').locator('p')).toHaveCount(currentChatCount + 1, { timeout: 10000 })
+ await expect(page.locator('#chattext').locator('ep-chat-message')).toHaveCount(currentChatCount + 1, { timeout: 10000 })
}
export const isChatBoxShown = async (page: Page) => {
diff --git a/playwright/helper/settingsHelper.ts b/playwright/helper/settingsHelper.ts
index 5483cd00..54a33a6e 100644
--- a/playwright/helper/settingsHelper.ts
+++ b/playwright/helper/settingsHelper.ts
@@ -1,4 +1,5 @@
import {expect, Page} from "@playwright/test";
+import {setEpCheckbox} from "./padHelper";
export const isSettingsShown = async (page: Page) => {
const classes = await page.locator('#settings').getAttribute('class')
@@ -17,18 +18,16 @@ export const hideSettings = async (page: Page) => {
await expect(page.locator('#settings')).not.toHaveClass(/popup-show/, { timeout: 5000 })
}
+// #options-stickychat is an , so Playwright's native
+// .isChecked()/.check()/.uncheck()/toBeChecked() do not apply.
export const enableStickyChatviaSettings = async (page: Page) => {
const stickyChat = page.locator('#options-stickychat')
await stickyChat.waitFor({ state: 'visible', timeout: 5000 });
- if (await stickyChat.isChecked()) return
- await stickyChat.check({ force: true })
- await expect(stickyChat).toBeChecked({ timeout: 5000 });
+ await setEpCheckbox(stickyChat, true);
}
export const disableStickyChat = async (page: Page) => {
const stickyChat = page.locator('#options-stickychat')
await stickyChat.waitFor({ state: 'visible', timeout: 5000 });
- if (!await stickyChat.isChecked()) return
- await stickyChat.uncheck({ force: true })
- await expect(stickyChat).not.toBeChecked({ timeout: 5000 });
+ await setEpCheckbox(stickyChat, false);
}
diff --git a/playwright/specs/chat.spec.ts b/playwright/specs/chat.spec.ts
index a3a8ad75..bb959cf9 100644
--- a/playwright/specs/chat.spec.ts
+++ b/playwright/specs/chat.spec.ts
@@ -31,10 +31,14 @@ test('opens chat, sends a message, makes sure it exists on the page and hides ch
const time = await getChatTime(page)
const chatMessage = await getChatMessage(page)
- expect(username).toBe('unnamed:');
+ // After the WebComponents migration chat messages render as
+ // with `author` and `time` as attributes. No trailing colon on the username,
+ // and the message body has no leading space — those artifacts were from the
+ // previous name: … body
layout.
+ expect(username).toBe('unnamed');
const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$');
expect(time).toMatch(regex);
- expect(chatMessage).toBe(" "+chatValue);
+ expect(chatMessage).toBe(chatValue);
})
test("makes sure that an empty message can't be sent", async function ({page}) {
@@ -53,10 +57,10 @@ test("makes sure that an empty message can't be sent", async function ({page}) {
const time = await getChatTime(page);
const chatMessage = await getChatMessage(page);
- expect(username).toBe('unnamed:');
+ expect(username).toBe('unnamed');
const regex = new RegExp('^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$');
expect(time).toMatch(regex);
- expect(chatMessage).toBe(" "+chatValue);
+ expect(chatMessage).toBe(chatValue);
});
test('makes chat stick to right side of the screen via settings, remove sticky via settings, close it', async ({page}) =>{
diff --git a/playwright/specs/font_type.spec.ts b/playwright/specs/font_type.spec.ts
index cace98f2..f7de96ef 100644
--- a/playwright/specs/font_type.spec.ts
+++ b/playwright/specs/font_type.spec.ts
@@ -1,5 +1,5 @@
import {expect, Page, test} from "@playwright/test";
-import {goToNewPad} from "../helper/padHelper";
+import {goToNewPad, selectEpDropdownItem} from "../helper/padHelper";
import {showSettings} from "../helper/settingsHelper";
test.beforeEach(async ({ page })=>{
@@ -19,9 +19,7 @@ test.describe('font select', function () {
test('makes text RobotoMono', async function ({page}) {
await showSettings(page);
- const fontMenu = page.locator('#viewfontmenu');
- await fontMenu.selectOption('RobotoMono');
- await expect(fontMenu).toHaveValue('RobotoMono');
+ await selectEpDropdownItem(page, '#viewfontmenu', 'RobotoMono');
// Check if font changed to RobotoMono
await expect.poll(async () => {
@@ -31,12 +29,12 @@ test.describe('font select', function () {
test('resets to normal font type', async function ({page}) {
await showSettings(page);
- const fontMenu = page.locator('#viewfontmenu');
- await fontMenu.selectOption('RobotoMono');
- await expect(fontMenu).toHaveValue('RobotoMono');
+ await selectEpDropdownItem(page, '#viewfontmenu', 'RobotoMono');
+ await expect.poll(async () => {
+ return await getBodyFontFamily(page);
+ }).toContain('robotomono');
- await fontMenu.selectOption('');
- await expect(fontMenu).toHaveValue('');
+ await selectEpDropdownItem(page, '#viewfontmenu', '');
await expect.poll(async () => {
return await getBodyFontFamily(page);
diff --git a/playwright/specs/language.spec.ts b/playwright/specs/language.spec.ts
index cf97d0fa..cbcde5ae 100644
--- a/playwright/specs/language.spec.ts
+++ b/playwright/specs/language.spec.ts
@@ -1,5 +1,5 @@
import {expect, Page, test} from "@playwright/test";
-import {goToNewPad} from "../helper/padHelper";
+import {goToNewPad, selectEpDropdownItem} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
await page.context().clearCookies();
@@ -17,7 +17,7 @@ const selectLanguage = async (page: Page, language: string) => {
await expect(languageMenu).toBeVisible();
await Promise.all([
page.waitForLoadState('load'),
- languageMenu.selectOption(language),
+ selectEpDropdownItem(page, '#languagemenu', language),
]);
};
@@ -27,14 +27,12 @@ test.describe('Language select and change', function () {
test('makes text german', async function ({page}) {
await selectLanguage(page, 'de');
- await expect(page.locator('#languagemenu')).toHaveValue('de');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
});
test('makes text English', async function ({page}) {
await selectLanguage(page, 'de');
await selectLanguage(page, 'en');
- await expect(page.locator('#languagemenu')).toHaveValue('en');
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
@@ -46,14 +44,12 @@ test.describe('Language select and change', function () {
test('changes direction when picking an ltr lang', async function ({page}) {
await selectLanguage(page, 'ar');
await selectLanguage(page, 'en');
- await expect(page.locator('#languagemenu')).toHaveValue('en');
await expect(page.locator('html')).toHaveAttribute('dir', 'ltr');
});
test('keeps selected language after reload', async function ({page}) {
await selectLanguage(page, 'de');
await goToNewPad(page);
- await expect(page.locator('#languagemenu')).toHaveValue('de');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
});
diff --git a/playwright/specs/ordered_list.spec.ts b/playwright/specs/ordered_list.spec.ts
index edf18da7..4fadc92c 100644
--- a/playwright/specs/ordered_list.spec.ts
+++ b/playwright/specs/ordered_list.spec.ts
@@ -58,6 +58,7 @@ test.describe('ordered_list.js', function () {
test('indent and de-indent list item with keypress', async function ({page}) {
const padBody = await getPadBody(page);
+ await clearPadContent(page)
// get the first text element out of the inner iframe
const $firstTextElement = padBody.locator('div').first();
@@ -68,6 +69,11 @@ test.describe('ordered_list.js', function () {
const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')
await $insertorderedlistButton.click()
+ // Re-focus the editor: clicking the toolbar button moves focus off
+ // #innerdocbody, so a bare keyboard.press('Tab') would tab to the next
+ // toolbar button instead of triggering the editor's Tab handler.
+ await padBody.locator('div').first().click();
+ await page.keyboard.press('Home');
await page.keyboard.press('Tab')
await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1)
@@ -95,6 +101,13 @@ test.describe('ordered_list.js', function () {
const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')
await $insertorderedlistButton.click()
+ // Re-focus the editor and place the caret on the list line: the toolbar
+ // button click applies a changeset that shifts rep.selStart to a stale
+ // position, so subsequent indent commands need a fresh selection inside
+ // the new list line.
+ await padBody.locator('div').first().click();
+ await page.keyboard.press('Home');
+
const $indentButton = page.locator('.buttonicon-indent')
await $indentButton.dblclick() // make it indented twice
diff --git a/playwright/specs/unordered_list.spec.ts b/playwright/specs/unordered_list.spec.ts
index e1cbfa22..c02b9a91 100644
--- a/playwright/specs/unordered_list.spec.ts
+++ b/playwright/specs/unordered_list.spec.ts
@@ -115,6 +115,13 @@ test.describe('unordered_list.js', function () {
const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');
await $insertunorderedlistButton.click();
+ // Re-focus the editor and place the caret on the list line: the
+ // toolbar button click applies a changeset that shifts rep.selStart
+ // to a stale position, so the subsequent indent command needs a
+ // fresh selection inside the new list line.
+ await padBody.locator('div').first().click();
+ await page.keyboard.press('Home');
+
await page.locator('.buttonicon-indent').click();
await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1);
diff --git a/ui/package.json b/ui/package.json
index 3afcfaed..b0ba1e43 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -9,7 +9,7 @@
"preview": "vite preview"
},
"devDependencies": {
- "etherpad-webcomponents": "^0.0.9",
+ "etherpad-webcomponents": "^0.0.11",
"@types/node": "^25.6.0",
"esbuild": "0.28.0",
"typescript": "^6.0.2",
diff --git a/ui/src/js/pad_editbar.ts b/ui/src/js/pad_editbar.ts
index 6ac4b8e0..f7c69113 100644
--- a/ui/src/js/pad_editbar.ts
+++ b/ui/src/js/pad_editbar.ts
@@ -330,9 +330,12 @@ export const padeditbar = new class {
const qrImage = q('#qrcodeimg');
const qrLinkInput = q('#qrcodelinkinput');
if (!(qrImage instanceof HTMLImageElement) || !(qrLinkInput instanceof HTMLInputElement)) return;
- const {link} = this.getShareLinks(Boolean(readonlyInput instanceof HTMLInputElement && readonlyInput.checked));
+ // #qrreadonlyinput is an , not a native . Reading .checked
+ // works on both because the Lit component exposes a reflected `checked` property.
+ const isReadonly = Boolean((readonlyInput as any)?.checked);
+ const {link} = this.getShareLinks(isReadonly);
qrLinkInput.value = link;
- qrImage.src = this.getQrCodeSrc(Boolean(readonlyInput instanceof HTMLInputElement && readonlyInput.checked));
+ qrImage.src = this.getQrCodeSrc(isReadonly);
}
_syncToolbarScrollState() {
diff --git a/ui/src/js/pad_editor.ts b/ui/src/js/pad_editor.ts
index 9cc0622b..dfd415da 100644
--- a/ui/src/js/pad_editor.ts
+++ b/ui/src/js/pad_editor.ts
@@ -158,10 +158,21 @@ export const padeditor = (() => {
v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
self.ace.setProperty('rtlIsTrue', v);
+ // setProperty on the AceEditor only flips the editor body's direction.
+ // Etherpad's layout also depends on the dir attribute (the whole
+ // page flips), so mirror the setting here. The original ace2_inner.ts did
+ // this inside the editor; in the webcomponent port the editor is scoped
+ // to its own DOM, so the page-level update lives at the consumer layer.
+ document.documentElement.dir = v ? 'rtl' : 'ltr';
padutils.setCheckbox('#options-rtlcheck', v);
v = getOption('showLineNumbers', true);
self.ace.setProperty('showslinenumbers', v);
+ // #sidediv is outside the editor's scope (it's a sibling in
+ // #outerdocbody), so the webcomponent AceEditor cannot toggle its
+ // parent's class the way the original ace2_inner did.
+ document.getElementById('sidediv')
+ ?.parentElement?.classList.toggle('line-numbers-hidden', !v);
padutils.setCheckbox('#options-linenoscheck', v);
v = getOption('showAuthorColors', true);
From 7ccc5865da10160ac5a04fe7851abb37a05fd518 Mon Sep 17 00:00:00 2001
From: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
Date: Wed, 22 Apr 2026 18:20:42 +0200
Subject: [PATCH 07/10] Merging
---
settings.template.json | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/settings.template.json b/settings.template.json
index 33e11845..ed34d3bb 100644
--- a/settings.template.json
+++ b/settings.template.json
@@ -78,9 +78,11 @@
"trustProxy": false,
"cookie": {
"keyRotationInterval": 86400000,
+ "prefix": "",
"sameSite": "Lax",
"sessionLifetime": 864000000,
- "sessionRefreshInterval": 86400000
+ "sessionRefreshInterval": 86400000,
+ "sessionCleanup": true
},
"disableIPlogging": false,
"automaticReconnectionTimeout": 0,
@@ -105,7 +107,7 @@
},
"socketTransportProtocols": ["websocket", "polling"],
"socketIo": {
- "maxHttpBufferSize": 50000
+ "maxHttpBufferSize": 1048576
},
"loadTest": false,
"dumpOnUncleanExit": false,
From 1c6ea92914b44120062342a359ab5c5b84528aa6 Mon Sep 17 00:00:00 2001
From: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
Date: Wed, 22 Apr 2026 18:22:47 +0200
Subject: [PATCH 08/10] Merging
---
pnpm-lock.yaml | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 08b5ee29..1870760f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
etherpad-webcomponents:
- specifier: ^0.0.9
- version: 0.0.9
+ specifier: ^0.0.11
+ version: 0.0.11
devDependencies:
typescript:
specifier: ^5.6.3
@@ -116,8 +116,8 @@ importers:
specifier: 0.28.0
version: 0.28.0
etherpad-webcomponents:
- specifier: ^0.0.9
- version: 0.0.9
+ specifier: ^0.0.11
+ version: 0.0.11
typescript:
specifier: ^6.0.2
version: 6.0.2
@@ -850,8 +850,8 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
- etherpad-webcomponents@0.0.9:
- resolution: {integrity: sha512-w/jom2QxEjU0403sTAi44X2RPqL6kJhmjJLKrVa291jk3RQ50wZixDfTBIGDRRmkuwoLIOvTJ+ps5IiPbRrMIA==}
+ etherpad-webcomponents@0.0.11:
+ resolution: {integrity: sha512-4QrJuCAXHIAm7hxffAv4cIPaXesLLSd1rfJhpOvI8gaOCvjhMSJb9wDLItwjfCMnbTHkRPQTVdAZGwwqVkyM0Q==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
@@ -1696,7 +1696,7 @@ snapshots:
estree-walker@2.0.2: {}
- etherpad-webcomponents@0.0.9:
+ etherpad-webcomponents@0.0.11:
dependencies:
'@lit/reactive-element': 2.1.2
lit: 3.3.2
From 76f11d8d65fd97f658bf4a32bc1e3a108f18fecd Mon Sep 17 00:00:00 2001
From: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
Date: Wed, 22 Apr 2026 18:50:59 +0200
Subject: [PATCH 09/10] Merging
---
playwright/helper/padHelper.ts | 24 +++++++++++++++++++++-
playwright/helper/settingsHelper.ts | 10 +++++++++
playwright/specs/change_user_color.spec.ts | 21 ++++++++++++-------
playwright/specs/change_user_name.spec.ts | 11 +++++++---
4 files changed, 55 insertions(+), 11 deletions(-)
diff --git a/playwright/helper/padHelper.ts b/playwright/helper/padHelper.ts
index 4e308d48..3d55165f 100644
--- a/playwright/helper/padHelper.ts
+++ b/playwright/helper/padHelper.ts
@@ -39,10 +39,32 @@ export const isEpCheckboxChecked = (locator: Locator): Promise =>
// dropdown requires clicking its trigger button; choosing a value means
// clicking the matching . The component dispatches
// 'ep-dropdown-select' on pick, which consumer code listens for.
+//
+// The opened/closed state is driven by the reflected `open` boolean
+// attribute on the host. The component's shadow DOM keeps its
+// `.content-wrapper` (and therefore the slotted items) `display: none`
+// until `[open]` is set; clicking an item before that would race with
+// Lit's async `updated()` lifecycle and time out. So we:
+// 1. click the slotted trigger button,
+// 2. wait for the host to report `open`,
+// 3. confirm the item is present+visible,
+// 4. click it.
export const selectEpDropdownItem = async (page: Page, dropdownSelector: string, value: string) => {
const dropdown = page.locator(dropdownSelector);
+ await dropdown.waitFor({ state: 'visible', timeout: 10000 });
await dropdown.locator('[slot="trigger"]').click();
- await dropdown.locator(`ep-dropdown-item[value="${value}"]`).click();
+ // Re-open if a stale `_onDocClick` fired synchronously with our own
+ // click and immediately closed the dropdown.
+ await expect.poll(async () => {
+ const isOpen = await dropdown.evaluate((el: Element) => el.hasAttribute('open'));
+ if (!isOpen) {
+ await dropdown.locator('[slot="trigger"]').click().catch(() => {});
+ }
+ return isOpen;
+ }, { timeout: 10000 }).toBe(true);
+ const item = dropdown.locator(`ep-dropdown-item[value="${value}"]`);
+ await item.waitFor({ state: 'visible', timeout: 10000 });
+ await item.click();
}
export const selectAllText = async (page: Page) => {
diff --git a/playwright/helper/settingsHelper.ts b/playwright/helper/settingsHelper.ts
index 54a33a6e..351071d5 100644
--- a/playwright/helper/settingsHelper.ts
+++ b/playwright/helper/settingsHelper.ts
@@ -10,6 +10,16 @@ export const showSettings = async (page: Page) => {
if (await isSettingsShown(page)) return
await page.locator("button[class~='buttonicon-settings']").click()
await expect(page.locator('#settings')).toHaveClass(/popup-show/, { timeout: 5000 })
+ // The popup's scale(0.7) → none transform animates over 300ms. While
+ // that transform is non-identity it forms a containing block for any
+ // position: fixed descendants — which includes 's
+ // content-wrapper. Clicking a dropdown item before the transform
+ // settles positions the content relative to the still-transforming
+ // popup instead of the viewport, and Playwright can time out waiting
+ // for a stable, visible item. Wait for the transition to complete.
+ await page.locator('#settings').evaluate((el) =>
+ Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished.catch(() => {})))
+ );
}
export const hideSettings = async (page: Page) => {
diff --git a/playwright/specs/change_user_color.spec.ts b/playwright/specs/change_user_color.spec.ts
index d583a221..9e2bbcc5 100644
--- a/playwright/specs/change_user_color.spec.ts
+++ b/playwright/specs/change_user_color.spec.ts
@@ -86,16 +86,23 @@ test.describe('change user color', function () {
await showChat(page)
await sendChatMessage(page, 'O hi');
- // wait until the chat message shows up
- const chatP = page.locator('#chattext').locator('p')
- const chatText = await chatP.innerText();
+ // wait until the chat message shows up — chat now renders as
+ // webcomponents rather than elements.
+ const chatMsg = page.locator('#chattext').locator('ep-chat-message').first()
+ await expect(chatMsg).toBeVisible({timeout: 10000});
+ const chatText = (await chatMsg.textContent()) ?? '';
expect(chatText).toContain('O hi');
- const color = await chatP.evaluate((el) => {
- return window.getComputedStyle(el).getPropertyValue('background-color');
- }, chatText);
+ // The author color is rendered inside the shadow DOM on the
+ // `.author` span via inline `color: ${authorColor}`. Read it from
+ // the shadow root.
+ const authorColor = await chatMsg.evaluate((el) => {
+ const span = el.shadowRoot?.querySelector('.author') as HTMLElement | null;
+ if (!span) return '';
+ return window.getComputedStyle(span).getPropertyValue('color');
+ });
- expect(color).toBe(testColorRGB);
+ expect(authorColor).toBe(testColorRGB);
});
});
diff --git a/playwright/specs/change_user_name.spec.ts b/playwright/specs/change_user_name.spec.ts
index 37877240..605ec337 100644
--- a/playwright/specs/change_user_name.spec.ts
+++ b/playwright/specs/change_user_name.spec.ts
@@ -29,7 +29,12 @@ test('Own user name is shown when you enter a chat', async ({page})=> {
await showChat(page);
await sendChatMessage(page,chatMessage);
- const chatText = await page.locator('#chattext').locator('p').innerText();
- expect(chatText).toContain('😃')
- expect(chatText).toContain(chatMessage)
+ // Chat renders as webcomponents: the author name lives
+ // on the `author` attribute, and the message body is the slotted text.
+ const chatMsg = page.locator('#chattext').locator('ep-chat-message').first();
+ await expect(chatMsg).toBeVisible({timeout: 10000});
+ const author = (await chatMsg.getAttribute('author')) ?? '';
+ const body = (await chatMsg.textContent()) ?? '';
+ expect(author).toContain('😃');
+ expect(body).toContain(chatMessage);
});
From ef0b5c23f6df5a8619ec3ead6e02f7f61a778984 Mon Sep 17 00:00:00 2001
From: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
Date: Wed, 22 Apr 2026 20:32:58 +0200
Subject: [PATCH 10/10] Merging
---
playwright/helper/padHelper.ts | 59 ++++++++++++++++++----------------
ui/src/js/chat.ts | 35 +++++++++++++++++++-
2 files changed, 66 insertions(+), 28 deletions(-)
diff --git a/playwright/helper/padHelper.ts b/playwright/helper/padHelper.ts
index 3d55165f..07411655 100644
--- a/playwright/helper/padHelper.ts
+++ b/playwright/helper/padHelper.ts
@@ -35,36 +35,41 @@ export const isEpCheckboxChecked = (locator: Locator): Promise =>
locator.evaluate((el: Element) => el.hasAttribute('checked'));
// is a Lit web component, not a native , so
-// Playwright's .selectOption() / toHaveValue() do not apply. Opening the
-// dropdown requires clicking its trigger button; choosing a value means
-// clicking the matching . The component dispatches
-// 'ep-dropdown-select' on pick, which consumer code listens for.
+// Playwright's .selectOption() / toHaveValue() do not apply.
//
-// The opened/closed state is driven by the reflected `open` boolean
-// attribute on the host. The component's shadow DOM keeps its
-// `.content-wrapper` (and therefore the slotted items) `display: none`
-// until `[open]` is set; clicking an item before that would race with
-// Lit's async `updated()` lifecycle and time out. So we:
-// 1. click the slotted trigger button,
-// 2. wait for the host to report `open`,
-// 3. confirm the item is present+visible,
-// 4. click it.
+// Playwright's actionability checks (visibility in particular) don't
+// always cooperate with the way Lit projects slotted content into the
+// shadow DOM's `.content-wrapper` — even after the dropdown is open,
+// `ep-dropdown-item` is slotted through a fixed-position wrapper that
+// Playwright can report as not-visible on both Chromium and Firefox.
+// So we drive the component the same way `_selectItem()` does internally:
+// wait for the matching item to exist, then dispatch `ep-dropdown-select`
+// on the host. That's exactly what a real click would trigger, minus the
+// actionability gymnastics.
export const selectEpDropdownItem = async (page: Page, dropdownSelector: string, value: string) => {
const dropdown = page.locator(dropdownSelector);
- await dropdown.waitFor({ state: 'visible', timeout: 10000 });
- await dropdown.locator('[slot="trigger"]').click();
- // Re-open if a stale `_onDocClick` fired synchronously with our own
- // click and immediately closed the dropdown.
- await expect.poll(async () => {
- const isOpen = await dropdown.evaluate((el: Element) => el.hasAttribute('open'));
- if (!isOpen) {
- await dropdown.locator('[slot="trigger"]').click().catch(() => {});
- }
- return isOpen;
- }, { timeout: 10000 }).toBe(true);
- const item = dropdown.locator(`ep-dropdown-item[value="${value}"]`);
- await item.waitFor({ state: 'visible', timeout: 10000 });
- await item.click();
+ await dropdown.waitFor({ state: 'attached', timeout: 10000 });
+ // Ensure the target has been rendered into the
+ // dropdown before we try to select it.
+ await expect.poll(async () =>
+ await dropdown.evaluate(
+ (el: Element, v: string) => !!el.querySelector(`ep-dropdown-item[value="${CSS.escape(v)}"]`),
+ value,
+ ),
+ { timeout: 10000 },
+ ).toBe(true);
+ // Some select handlers (e.g. #languagemenu) call location.reload(),
+ // which detaches the frame mid-evaluate and causes evaluate() to reject.
+ // Swallow that — callers that care about the reload wrap us in
+ // Promise.all(page.waitForLoadState('load'), ...).
+ await dropdown.evaluate((el: any, v: string) => {
+ el.dispatchEvent(new CustomEvent('ep-dropdown-select', {
+ bubbles: true,
+ composed: true,
+ detail: { value: v },
+ }));
+ if (typeof el.close === 'function') el.close();
+ }, value).catch(() => {});
}
export const selectAllText = async (page: Page) => {
diff --git a/ui/src/js/chat.ts b/ui/src/js/chat.ts
index 9f82a5f5..1a832d84 100644
--- a/ui/src/js/chat.ts
+++ b/ui/src/js/chat.ts
@@ -3,6 +3,7 @@ import html10n from './i18n';
import notifications from './notifications';
import {editorBus} from './core/EventBus';
import {padeditor} from './pad_editor';
+import {paduserlist} from './pad_userlist';
import 'etherpad-webcomponents/EpChatMessage.js';
// ---------------------------------------------------------------------------
@@ -375,13 +376,45 @@ class ChatController {
const rendered = getRenderedElement(ctx.rendered);
const chatMsg = rendered ?? document.createElement('ep-chat-message');
if (rendered == null) {
- const myUserId = String((window as any).clientVars?.userId ?? '');
+ const cv: any = (window as any).clientVars ?? {};
+ const myUserId = String(cv.userId ?? '');
chatMsg.setAttribute('data-authorId', ctx.author);
chatMsg.setAttribute('author', ctx.authorName);
chatMsg.setAttribute('time', ctx.timeStr);
if (ctx.author === myUserId) {
chatMsg.setAttribute('own', '');
}
+ // Resolve the author's current color so can tint the
+ // name. Priority: own user's live color from pad.myUserInfo (tracks the
+ // color picker immediately, before the server round-trip updates
+ // historicalAuthorData) → live user list from paduserlist for other
+ // currently-connected users → historicalAuthorData from clientVars for
+ // authors who have left but contributed to the pad. Empty string
+ // leaves the default theme color.
+ const toCssColor = (c: unknown): string => {
+ if (typeof c === 'number') return cv.colorPalette?.[c] ?? '';
+ return typeof c === 'string' ? c : '';
+ };
+ const resolveAuthorColor = (): string => {
+ const myColor = (this.pad as any)?.myUserInfo?.colorId;
+ if (ctx.author === myUserId && myColor) return toCssColor(myColor);
+ try {
+ const list = paduserlist.users();
+ const u = list.find((x: any) => x?.userId === ctx.author);
+ if (u) {
+ const c = u.color ?? u.colorId;
+ const resolved = toCssColor(c);
+ if (resolved) return resolved;
+ }
+ } catch {
+ // paduserlist may not be initialized during handshake — fall through.
+ }
+ const historical = cv.collab_client_vars?.historicalAuthorData?.[ctx.author];
+ if (historical?.colorId) return toCssColor(historical.colorId);
+ return '';
+ };
+ const authorColor = resolveAuthorColor();
+ if (authorColor) chatMsg.setAttribute('author-color', authorColor);
const textContainer = document.createElement('span');
textContainer.innerHTML = ctx.text;
chatMsg.append(...Array.from(textContainer.childNodes));