From c7f381239c4c618599078a924ea23324d64e825a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:12:39 +0000 Subject: [PATCH 1/6] Initial plan From 3b945acd57be34bdb0b19442bb4fdda548219934 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:17:28 +0000 Subject: [PATCH 2/6] fix: return all matching pads in search Agent-Logs-Url: https://github.com/ether/ep_search/sessions/8b580260-2991-4422-840d-5cb9f9b1e12d Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- index.js | 16 +++++--- test/search.test.js | 90 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 test/search.test.js diff --git a/index.js b/index.js index d269788..338b71c 100644 --- a/index.js +++ b/index.js @@ -11,21 +11,25 @@ const async = require('ep_etherpad-lite/node_modules/async'); exports.registerRoute = (hookName, args, cb) => { args.app.get('/search', (req, res) => { - const searchString = req.query.query; - const result = {}; + const searchString = (req.query.query || '').toLowerCase(); + const result = []; + + if (!searchString) { + return res.json(result); + } db.findKeys('pad:*', '*:*:*', (err, pads) => { // get all pads async.forEachSeries(pads, (pad, callback) => { db.get(pad, (err, padData) => { // get the pad contents - const padText = padData.atext.text || ''; + const padText = padData?.atext?.text || ''; // does searchString exist in aText? - if (padText.toLowerCase().indexOf(searchString.toLowerCase()) !== -1) { - result.pad = pad; + if (padText.toLowerCase().indexOf(searchString) !== -1) { + result.push(pad); } callback(); }); }, (err) => { - res.send(JSON.stringify(result)); + res.json(result); }); }); }); diff --git a/test/search.test.js b/test/search.test.js new file mode 100644 index 0000000..7e8e724 --- /dev/null +++ b/test/search.test.js @@ -0,0 +1,90 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const Module = require('node:module'); +const path = require('node:path'); + +const indexPath = path.join(__dirname, '..', 'index.js'); + +const loadPlugin = (pads) => { + let findKeysCalls = 0; + const fakeDb = { + findKeys: (key, pattern, callback) => { + findKeysCalls++; + callback(null, Object.keys(pads)); + }, + get: (pad, callback) => callback(null, pads[pad]), + }; + const fakeAsync = { + forEachSeries: (items, iterator, done) => { + let i = 0; + const next = (err) => { + if (err != null || i >= items.length) return done(err); + iterator(items[i++], next); + }; + next(); + }, + }; + + const originalLoad = Module._load; + Module._load = function (request, parent, isMain) { + if (request === 'ep_etherpad-lite/node/db/DB') return {db: fakeDb}; + if (request === 'ep_etherpad-lite/node_modules/async') return fakeAsync; + return originalLoad.call(this, request, parent, isMain); + }; + + delete require.cache[indexPath]; + const plugin = require(indexPath); + Module._load = originalLoad; + + return { + findKeysCalls: () => findKeysCalls, + plugin, + }; +}; + +const runSearch = async (plugin, query) => { + let handler; + plugin.registerRoute(null, { + app: { + get: (route, routeHandler) => { + assert.equal(route, '/search'); + handler = routeHandler; + }, + }, + }, () => {}); + + return await new Promise((resolve) => { + handler({query: {query}}, { + json: (body) => resolve(body), + }); + }); +}; + +const searchReturnsEveryMatchingPad = async () => { + const {plugin} = loadPlugin({ + 'pad:first': {atext: {text: 'needle in the first pad'}}, + 'pad:second': {atext: {text: 'Needle in the second pad'}}, + 'pad:third': {atext: {text: 'does not match'}}, + }); + + assert.deepEqual(await runSearch(plugin, 'needle'), ['pad:first', 'pad:second']); +}; + +const emptySearchesShortCircuit = async () => { + const {findKeysCalls, plugin} = loadPlugin({ + 'pad:first': {atext: {text: 'needle in the first pad'}}, + }); + + assert.deepEqual(await runSearch(plugin, ''), []); + assert.equal(findKeysCalls(), 0); +}; + +(async () => { + await searchReturnsEveryMatchingPad(); + await emptySearchesShortCircuit(); + console.log('search tests passed'); +})().catch((err) => { + console.error(err); + process.exitCode = 1; +}); From e9b0ad3adc6e1c6cf9747b8bcbd586ba8536fead Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:18:36 +0000 Subject: [PATCH 3/6] test: tidy search regression coverage Agent-Logs-Url: https://github.com/ether/ep_search/sessions/8b580260-2991-4422-840d-5cb9f9b1e12d Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- test/search.test.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/search.test.js b/test/search.test.js index 7e8e724..fc55e4d 100644 --- a/test/search.test.js +++ b/test/search.test.js @@ -5,6 +5,7 @@ const Module = require('node:module'); const path = require('node:path'); const indexPath = path.join(__dirname, '..', 'index.js'); +const NO_OP = () => {}; const loadPlugin = (pads) => { let findKeysCalls = 0; @@ -17,10 +18,10 @@ const loadPlugin = (pads) => { }; const fakeAsync = { forEachSeries: (items, iterator, done) => { - let i = 0; + let itemIndex = 0; const next = (err) => { - if (err != null || i >= items.length) return done(err); - iterator(items[i++], next); + if (err != null || itemIndex >= items.length) return done(err); + iterator(items[itemIndex++], next); }; next(); }, @@ -52,7 +53,7 @@ const runSearch = async (plugin, query) => { handler = routeHandler; }, }, - }, () => {}); + }, NO_OP); return await new Promise((resolve) => { handler({query: {query}}, { From 46b5f52947e935a64b808cd02a7d8c58ae5004a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:19:26 +0000 Subject: [PATCH 4/6] test: remove redundant await in search script Agent-Logs-Url: https://github.com/ether/ep_search/sessions/8b580260-2991-4422-840d-5cb9f9b1e12d Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- test/search.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/search.test.js b/test/search.test.js index fc55e4d..29fe0aa 100644 --- a/test/search.test.js +++ b/test/search.test.js @@ -55,7 +55,7 @@ const runSearch = async (plugin, query) => { }, }, NO_OP); - return await new Promise((resolve) => { + return new Promise((resolve) => { handler({query: {query}}, { json: (body) => resolve(body), }); From 646c9328b33f823831ad8859c599a3e0f551761d Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 14 May 2026 13:49:02 +0100 Subject: [PATCH 5/6] fix: clean up search UI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- index.js | 28 ++++ locales/en.json | 11 ++ static/css/search.css | 79 +++++++++++ static/tests/frontend-new/specs/smoke.spec.ts | 20 +++ templates/search.html | 126 ++++++++++++++---- test/search.test.js | 48 ++++++- 6 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 locales/en.json create mode 100644 static/css/search.css diff --git a/index.js b/index.js index 338b71c..636f62a 100644 --- a/index.js +++ b/index.js @@ -4,13 +4,41 @@ // when someone begins editing and when someone finishes. const db = require('ep_etherpad-lite/node/db/DB').db; const async = require('ep_etherpad-lite/node_modules/async'); +const log4js = require('ep_etherpad-lite/node_modules/log4js'); +const logger = log4js.getLogger('ep_search'); // Settings -- EDIT THESE IN settings.json not here.. // var pluginSettings = settings.ep_search; // var checkFrequency = pluginSettings.checkFrequency || 60000; // 10 seconds +const SEARCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; +const SEARCH_MAX_REQUESTS_PER_WINDOW = 10; +const ipRequestCounts = {}; + +const checkRateLimit = (ip) => { + const now = Date.now(); + if (!ipRequestCounts[ip] || now - ipRequestCounts[ip].windowStart > SEARCH_RATE_LIMIT_WINDOW_MS) { + ipRequestCounts[ip] = {windowStart: now, count: 0}; + } + ipRequestCounts[ip].count++; + return ipRequestCounts[ip].count > SEARCH_MAX_REQUESTS_PER_WINDOW; +}; + +const ipCleanup = setInterval(() => { + const now = Date.now(); + for (const [ip, data] of Object.entries(ipRequestCounts)) { + if (now - data.windowStart > SEARCH_RATE_LIMIT_WINDOW_MS * 2) delete ipRequestCounts[ip]; + } +}, 5 * 60 * 1000); +ipCleanup.unref(); exports.registerRoute = (hookName, args, cb) => { args.app.get('/search', (req, res) => { + const clientIp = req.ip || req.connection?.remoteAddress || 'unknown'; + if (checkRateLimit(clientIp)) { + logger.warn(`Search rate limit exceeded for ${clientIp}`); + return res.status(429).json({error: 'Rate limit exceeded'}); + } + const searchString = (req.query.query || '').toLowerCase(); const result = []; diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..a7f0514 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,11 @@ +{ + "ep_search.heading": "Search pads", + "ep_search.help": "Find pads containing the text you enter.", + "ep_search.label": "Search query", + "ep_search.placeholder": "Search all pads", + "ep_search.submit": "Search", + "ep_search.searching": "Searching...", + "ep_search.results": "Matching pads: {count}", + "ep_search.noResults": "No matching pads found.", + "ep_search.error": "Search failed. Please try again." +} diff --git a/static/css/search.css b/static/css/search.css new file mode 100644 index 0000000..ff27fb3 --- /dev/null +++ b/static/css/search.css @@ -0,0 +1,79 @@ +.ep-search-panel { + margin: 1rem auto; + max-width: 52rem; + padding: 1rem; + border: 1px solid #d7d7d7; + border-radius: 8px; + background: #fff; + box-shadow: 0 1px 3px rgb(0 0 0 / 8%); +} + +.ep-search-panel__header { + margin-bottom: 0.75rem; +} + +.ep-search-panel__header h2 { + margin: 0 0 0.25rem; + font-size: 1.1rem; +} + +.ep-search-panel__header p, +.ep-search-panel__status { + margin: 0; + color: #555; +} + +.ep-search-panel__form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ep-search-panel__controls { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; +} + +.ep-search-panel__controls input { + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid #bdbdbd; + border-radius: 6px; + box-sizing: border-box; +} + +.ep-search-panel__controls button { + align-self: flex-start; + padding: 0.55rem 1rem; + border: 1px solid #1863a1; + border-radius: 6px; + background: #1f7ac9; + color: #fff; + cursor: pointer; +} + +.ep-search-panel__controls button[type="submit"] { + position: static !important; + left: auto !important; + top: auto !important; +} + +.ep-search-panel__controls button:disabled { + opacity: 0.65; + cursor: wait; +} + +.ep-search-panel__results { + margin: 0.75rem 0 0; + padding-left: 1.25rem; +} + +.ep-search-panel__results li + li { + margin-top: 0.35rem; +} + +.ep-search-panel__results a { + overflow-wrap: anywhere; +} diff --git a/static/tests/frontend-new/specs/smoke.spec.ts b/static/tests/frontend-new/specs/smoke.spec.ts index 927551a..05888d1 100644 --- a/static/tests/frontend-new/specs/smoke.spec.ts +++ b/static/tests/frontend-new/specs/smoke.spec.ts @@ -10,4 +10,24 @@ test.describe('ep_search', () => { const padBody = await getPadBody(page); await expect(padBody).toBeVisible(); }); + + test('renders search results as a proper list of links', async ({page}) => { + await page.route('**/search/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(['pad:first', 'pad:second']), + }); + }); + + await page.locator('#ep-search-input').fill('needle'); + await page.locator('#ep-search-submit').click(); + + const items = page.locator('#ep-search-results li'); + await expect(items).toHaveCount(2); + await expect(items.nth(0).locator('a')).toHaveAttribute('href', '/p/first'); + await expect(items.nth(0).locator('a')).toHaveText('first'); + await expect(items.nth(1).locator('a')).toHaveAttribute('href', '/p/second'); + await expect(page.locator('#ep-search-status')).toContainText('Matching pads: 2'); + }); }); diff --git a/templates/search.html b/templates/search.html index 30901ac..5ac0665 100644 --- a/templates/search.html +++ b/templates/search.html @@ -1,29 +1,103 @@ - -
- -
- - -
-
- - diff --git a/test/search.test.js b/test/search.test.js index 29fe0aa..059e2dd 100644 --- a/test/search.test.js +++ b/test/search.test.js @@ -31,6 +31,9 @@ const loadPlugin = (pads) => { Module._load = function (request, parent, isMain) { if (request === 'ep_etherpad-lite/node/db/DB') return {db: fakeDb}; if (request === 'ep_etherpad-lite/node_modules/async') return fakeAsync; + if (request === 'ep_etherpad-lite/node_modules/log4js') { + return {getLogger: () => ({warn: NO_OP})}; + } return originalLoad.call(this, request, parent, isMain); }; @@ -44,7 +47,7 @@ const loadPlugin = (pads) => { }; }; -const runSearch = async (plugin, query) => { +const runSearch = async (plugin, query, ip = '127.0.0.1') => { let handler; plugin.registerRoute(null, { app: { @@ -56,12 +59,26 @@ const runSearch = async (plugin, query) => { }, NO_OP); return new Promise((resolve) => { - handler({query: {query}}, { - json: (body) => resolve(body), - }); + const response = { + statusCode: 200, + status(code) { + this.statusCode = code; + return this; + }, + json(body) { + resolve({status: this.statusCode, body}); + }, + }; + handler({query: {query}, ip}, response); }); }; +const expectSearchBody = async (plugin, query, ip) => { + const {status, body} = await runSearch(plugin, query, ip); + assert.equal(status, 200); + return body; +}; + const searchReturnsEveryMatchingPad = async () => { const {plugin} = loadPlugin({ 'pad:first': {atext: {text: 'needle in the first pad'}}, @@ -69,7 +86,10 @@ const searchReturnsEveryMatchingPad = async () => { 'pad:third': {atext: {text: 'does not match'}}, }); - assert.deepEqual(await runSearch(plugin, 'needle'), ['pad:first', 'pad:second']); + assert.deepEqual( + await expectSearchBody(plugin, 'needle', 'matching-ip'), + ['pad:first', 'pad:second'], + ); }; const emptySearchesShortCircuit = async () => { @@ -77,13 +97,29 @@ const emptySearchesShortCircuit = async () => { 'pad:first': {atext: {text: 'needle in the first pad'}}, }); - assert.deepEqual(await runSearch(plugin, ''), []); + assert.deepEqual(await expectSearchBody(plugin, '', 'empty-ip'), []); assert.equal(findKeysCalls(), 0); }; +const rateLimitingReturns429 = async () => { + const {plugin} = loadPlugin({ + 'pad:first': {atext: {text: 'needle in the first pad'}}, + }); + + for (let i = 0; i < 10; i++) { + const {status} = await runSearch(plugin, 'needle', 'rate-limited-ip'); + assert.equal(status, 200); + } + + const {status, body} = await runSearch(plugin, 'needle', 'rate-limited-ip'); + assert.equal(status, 429); + assert.deepEqual(body, {error: 'Rate limit exceeded'}); +}; + (async () => { await searchReturnsEveryMatchingPad(); await emptySearchesShortCircuit(); + await rateLimitingReturns429(); console.log('search tests passed'); })().catch((err) => { console.error(err); From b851cac409c27fe26c1dc5160f4da8dc57cde9ea Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 14 May 2026 21:23:53 +0100 Subject: [PATCH 6/6] Fix search frontend coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- locales/en.json | 2 +- static/tests/frontend-new/specs/smoke.spec.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/locales/en.json b/locales/en.json index a7f0514..8111651 100644 --- a/locales/en.json +++ b/locales/en.json @@ -5,7 +5,7 @@ "ep_search.placeholder": "Search all pads", "ep_search.submit": "Search", "ep_search.searching": "Searching...", - "ep_search.results": "Matching pads: {count}", + "ep_search.results": "Matching pads: {{count}}", "ep_search.noResults": "No matching pads found.", "ep_search.error": "Search failed. Please try again." } diff --git a/static/tests/frontend-new/specs/smoke.spec.ts b/static/tests/frontend-new/specs/smoke.spec.ts index 05888d1..5ab2194 100644 --- a/static/tests/frontend-new/specs/smoke.spec.ts +++ b/static/tests/frontend-new/specs/smoke.spec.ts @@ -1,14 +1,13 @@ import {expect, test} from '@playwright/test'; -import {getPadBody, goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper'; test.beforeEach(async ({page}) => { - await goToNewPad(page); + await page.goto('http://localhost:9001/'); }); test.describe('ep_search', () => { - test('pad loads with plugin installed', async ({page}) => { - const padBody = await getPadBody(page); - await expect(padBody).toBeVisible(); + test('index page renders the search panel', async ({page}) => { + await expect(page.locator('#ep-search-panel')).toBeVisible(); + await expect(page.locator('#ep-search-input')).toBeVisible(); }); test('renders search results as a proper list of links', async ({page}) => { @@ -20,6 +19,7 @@ test.describe('ep_search', () => { }); }); + await expect(page.locator('#ep-search-input')).toBeVisible(); await page.locator('#ep-search-input').fill('needle'); await page.locator('#ep-search-submit').click();