diff --git a/.github/workflows/upgrade-from-latest-release.yml b/.github/workflows/upgrade-from-latest-release.yml index c3ef545f519..3ff00f57547 100644 --- a/.github/workflows/upgrade-from-latest-release.yml +++ b/.github/workflows/upgrade-from-latest-release.yml @@ -84,9 +84,6 @@ jobs: - name: Run the backend tests run: pnpm run test - - - name: Install all dependencies and symlink for ep_etherpad-lite - run: pnpm install --frozen-lockfile # The job starts from the latest release tag, so fetch the pull-request # ref explicitly before checking out ${GITHUB_SHA}. A plain "git fetch" # only brings "normal" references (refs/heads/* and refs/tags/*), and for @@ -99,5 +96,8 @@ jobs: # For pull requests, ${GITHUB_SHA} is the automatically generated merge # commit that merges the PR's source branch to its destination branch. run: git checkout "${GITHUB_SHA}" + - + name: Install all dependencies and symlink for ep_etherpad-lite + run: pnpm install --frozen-lockfile - name: Run the backend tests run: pnpm run test diff --git a/admin/src/pages/PadPage.tsx b/admin/src/pages/PadPage.tsx index c216ffab79f..039c10b578d 100644 --- a/admin/src/pages/PadPage.tsx +++ b/admin/src/pages/PadPage.tsx @@ -418,7 +418,7 @@ export const PadPage = () => { diff --git a/src/static/js/pad_userlist.ts b/src/static/js/pad_userlist.ts index d3ee825276c..7366348a1f0 100644 --- a/src/static/js/pad_userlist.ts +++ b/src/static/js/pad_userlist.ts @@ -557,7 +557,7 @@ const paduserlist = (() => { if (localStorage.getItem('recentPads') != null) { const recentPadsList = JSON.parse(localStorage.getItem('recentPads')); const pathSegments = window.location.pathname.split('/'); - const padName = pathSegments[pathSegments.length - 1]; + const padName = decodeURIComponent(pathSegments[pathSegments.length - 1]); const existingPad = recentPadsList.find((pad) => pad.name === padName); if (existingPad) { existingPad.members = online; diff --git a/src/static/skins/colibris/index.js b/src/static/skins/colibris/index.js index f36edf84180..3da389330bc 100644 --- a/src/static/skins/colibris/index.js +++ b/src/static/skins/colibris/index.js @@ -70,7 +70,7 @@ window.customStart = () => { li.style.cursor = 'pointer'; li.className = 'recent-pad'; - const padPath = `${window.location.href}p/${pad.name}`; + const padPath = `${window.location.href}p/${encodeURIComponent(pad.name)}`; const link = document.createElement('a'); link.style.textDecoration = 'none'; diff --git a/src/static/skins/colibris/pad.js b/src/static/skins/colibris/pad.js index 1e7a85b3faa..ce509a8892b 100644 --- a/src/static/skins/colibris/pad.js +++ b/src/static/skins/colibris/pad.js @@ -8,7 +8,7 @@ window.customStart = () => { $('.buttonicon').on('mouseup', function () { $(this).parent().removeClass('pressed'); }); const pathSegments = window.location.pathname.split('/'); - const padName = pathSegments[pathSegments.length - 1]; + const padName = decodeURIComponent(pathSegments[pathSegments.length - 1]); const recentPads = localStorage.getItem('recentPads'); if (recentPads == null) { localStorage.setItem('recentPads', JSON.stringify([])); diff --git a/src/tests/frontend-new/specs/embed_value.spec.ts b/src/tests/frontend-new/specs/embed_value.spec.ts index c4abe8201ba..0d8ca2de438 100644 --- a/src/tests/frontend-new/specs/embed_value.spec.ts +++ b/src/tests/frontend-new/specs/embed_value.spec.ts @@ -133,4 +133,106 @@ test.describe('embed links', function () { await checkiFrameCode(embedCode, true, page); }); }) + + test.describe('UI interactions and accessibility', function () { + test.beforeEach(async ({ page }) => { + await goToNewPad(page); + }); + + test('focuses the dialog container on open', async function ({page}) { + const shareButton = page.locator('button[data-l10n-id="pad.toolbar.embed.title"]'); + await shareButton.click(); + + const dialog = page.locator('#embed'); + await expect(dialog).toBeFocused(); + }); + + test('clicking inside inputs selects the entire text content', async function ({page}) { + const shareButton = page.locator('button[data-l10n-id="pad.toolbar.embed.title"]'); + await shareButton.click(); + + // Focus another element first to clear selection/focus + const embedInput = page.locator('#embedinput'); + await embedInput.click(); + + // Verify embedinput is fully selected on click + let selection = await page.evaluate(() => { + const activeEl = document.activeElement as HTMLInputElement; + return { + id: activeEl?.id, + selectionStart: activeEl?.selectionStart, + selectionEnd: activeEl?.selectionEnd, + valueLength: activeEl?.value.length, + }; + }); + expect(selection.id).toBe('embedinput'); + expect(selection.selectionStart).toBe(0); + expect(selection.selectionEnd).toBe(selection.valueLength); + + // Now click linkinput + const linkInput = page.locator('#linkinput'); + await linkInput.click(); + + selection = await page.evaluate(() => { + const activeEl = document.activeElement as HTMLInputElement; + return { + id: activeEl?.id, + selectionStart: activeEl?.selectionStart, + selectionEnd: activeEl?.selectionEnd, + valueLength: activeEl?.value.length, + }; + }); + expect(selection.id).toBe('linkinput'); + expect(selection.selectionStart).toBe(0); + expect(selection.selectionEnd).toBe(selection.valueLength); + }); + + test('Escape key closes the dialog and restores focus to the trigger', async function ({page}) { + const shareButton = page.locator('button[data-l10n-id="pad.toolbar.embed.title"]'); + await shareButton.click(); + + const dialog = page.locator('#embed'); + await expect(dialog).toHaveClass(/popup-show/); + // Wait for focus to land on the dialog to prevent any asynchronous race conditions under load + await expect(dialog).toBeFocused(); + + await page.keyboard.press('Escape'); + await expect(dialog).not.toHaveClass(/popup-show/); + + // Verify focus is restored to the share button + const focusedL10nId = await page.evaluate(() => document.activeElement?.getAttribute('data-l10n-id') || ''); + expect(focusedL10nId).toBe('pad.toolbar.embed.title'); + }); + + test('bi-directional checkbox toggling updates links accordingly', async function ({page}) { + const shareButton = page.locator('button[data-l10n-id="pad.toolbar.embed.title"]'); + await shareButton.click(); + + const linkInput = page.locator('#linkinput'); + const embedInput = page.locator('#embedinput'); + const readonlyCheckbox = page.locator('#readonlyinput'); + + // Unchecked by default: should be read-write + const initialLink = await linkInput.inputValue(); + const initialEmbed = await embedInput.inputValue(); + expect(initialLink.indexOf('r.') > 0).toBe(false); + expect(initialEmbed.indexOf('r.') > 0).toBe(false); + + // Check it -> updates to read-only + await readonlyCheckbox.click({force: true}); + await page.waitForSelector('#readonlyinput:checked'); + const roLink = await linkInput.inputValue(); + const roEmbed = await embedInput.inputValue(); + expect(roLink.indexOf('r.') > 0).toBe(true); + expect(roEmbed.indexOf('embed_readonly') > 0).toBe(true); + + // Uncheck it -> updates back to read-write + await readonlyCheckbox.click({force: true}); + await page.waitForSelector('#readonlyinput:not(:checked)'); + const rwLink = await linkInput.inputValue(); + const rwEmbed = await embedInput.inputValue(); + expect(rwLink).toBe(initialLink); + expect(rwEmbed).toBe(initialEmbed); + }); + }); }) diff --git a/src/tests/frontend-new/specs/recent_pads.spec.ts b/src/tests/frontend-new/specs/recent_pads.spec.ts new file mode 100644 index 00000000000..a73ef83aa76 --- /dev/null +++ b/src/tests/frontend-new/specs/recent_pads.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Recent Pads', () => { + test('should display correctly encoded URLs for recent pads', async ({ page }) => { + const padName = 'test pad with spaces & / chars'; + const recentPads = [ + { + name: padName, + timestamp: new Date().toISOString(), + members: 1, + }, + ]; + + // Add recent pad to localStorage before navigating + await page.addInitScript((data) => { + window.localStorage.setItem('recentPads', data); + }, JSON.stringify(recentPads)); + + await page.goto('localhost:9001/'); + + const recentPad = page.locator('.recent-pad').first(); + await expect(recentPad).toBeVisible(); + + const link = recentPad.locator('a'); + await expect(link).toHaveText(padName); + + // Assert the href has the properly encoded URL + const expectedEncodedName = encodeURIComponent(padName); + const expectedHrefRegex = new RegExp(`p/${expectedEncodedName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); + await expect(link).toHaveAttribute('href', expectedHrefRegex); + }); +});