ep_hljs 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.cjs +10 -0
- package/.github/workflows/automerge.yml +45 -0
- package/.github/workflows/backend-tests.yml +75 -0
- package/.github/workflows/codeql.yml +41 -0
- package/.github/workflows/frontend-tests.yml +72 -0
- package/.github/workflows/npmpublish.yml +124 -0
- package/.github/workflows/test-and-release.yml +32 -0
- package/CLAUDE.md +141 -0
- package/LICENSE +201 -0
- package/README.md +56 -0
- package/demo.gif +0 -0
- package/demo.png +0 -0
- package/ep.json +26 -0
- package/index.js +109 -0
- package/lib/exportRenderer.js +39 -0
- package/lib/languageAllowlist.js +16 -0
- package/lib/padLanguageStore.js +27 -0
- package/locales/en.json +10 -0
- package/package.json +59 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/build-vendor.js +45 -0
- package/static/css/editor.css +94 -0
- package/static/css/themes/github-dark.css +118 -0
- package/static/css/themes/github.css +118 -0
- package/static/js/codeIndent.js +107 -0
- package/static/js/constants.js +7 -0
- package/static/js/domOverlay.js +16 -0
- package/static/js/highlightRegistry.js +109 -0
- package/static/js/hljsAdapter.js +80 -0
- package/static/js/index.js +124 -0
- package/static/js/lruCache.js +28 -0
- package/static/js/syntaxRenderer.js +201 -0
- package/static/js/themeBridge.js +76 -0
- package/static/js/vendor/hljs.min.js +5 -0
- package/static/tests/backend/specs/codeIndent.test.js +144 -0
- package/static/tests/backend/specs/export.test.js +47 -0
- package/static/tests/backend/specs/highlightRegistry.test.js +59 -0
- package/static/tests/backend/specs/hljsAdapter.test.js +43 -0
- package/static/tests/backend/specs/lruCache.test.js +45 -0
- package/static/tests/backend/specs/padLanguageStore.test.js +63 -0
- package/static/tests/backend/specs/socket.test.js +54 -0
- package/static/tests/frontend-new/helper/highlights.ts +64 -0
- package/static/tests/frontend-new/specs/caret-stability.spec.ts +106 -0
- package/static/tests/frontend-new/specs/code-indent.spec.ts +78 -0
- package/static/tests/frontend-new/specs/collaboration.spec.ts +59 -0
- package/static/tests/frontend-new/specs/content-sync.spec.ts +45 -0
- package/static/tests/frontend-new/specs/dark-mode.spec.ts +87 -0
- package/static/tests/frontend-new/specs/export.spec.ts +31 -0
- package/static/tests/frontend-new/specs/initial-paint.spec.ts +49 -0
- package/static/tests/frontend-new/specs/language-picker.spec.ts +54 -0
- package/static/tests/frontend-new/specs/large-pad.spec.ts +36 -0
- package/static/tests/frontend-new/specs/lifecycle.spec.ts +27 -0
- package/static/tests/frontend-new/specs/multi-user-caret.spec.ts +167 -0
- package/static/tests/frontend-new/specs/single-line-while.spec.ts +50 -0
- package/templates/editbarButtons.ejs +29 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {expect, test, Page} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
|
|
4
|
+
test.setTimeout(20_000);
|
|
5
|
+
|
|
6
|
+
const inner = (page: Page) => page
|
|
7
|
+
.frameLocator('iframe[name="ace_outer"]')
|
|
8
|
+
.frameLocator('iframe[name="ace_inner"]');
|
|
9
|
+
|
|
10
|
+
const setupPad = async (page: Page) => {
|
|
11
|
+
await goToNewPad(page);
|
|
12
|
+
await page.waitForTimeout(1500);
|
|
13
|
+
await inner(page).locator('body').click();
|
|
14
|
+
await page.keyboard.press('Control+A');
|
|
15
|
+
await page.keyboard.press('Delete');
|
|
16
|
+
await page.waitForTimeout(300);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const pickJS = async (page: Page) => {
|
|
20
|
+
const niceWrapper = page.locator('#ep_hljs_li .nice-select');
|
|
21
|
+
if (await niceWrapper.count() > 0) {
|
|
22
|
+
await niceWrapper.click();
|
|
23
|
+
await page.locator('#ep_hljs_li .nice-select .option[data-value="javascript"]').click();
|
|
24
|
+
} else {
|
|
25
|
+
await page.locator('#ep_hljs_select').selectOption('javascript');
|
|
26
|
+
}
|
|
27
|
+
await inner(page).locator('body').click();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// FIXME: these Playwright cases race with Etherpad's keystroke pipeline in
|
|
31
|
+
// ways that don't reproduce in a real browser session — manual testing
|
|
32
|
+
// confirms Enter / Tab / Shift+Tab behave correctly. The codeIndent backend
|
|
33
|
+
// unit specs (static/tests/backend/specs/codeIndent.test.js) cover the
|
|
34
|
+
// handleEnter / handleTab / handleShiftTab logic with mocked rep + editorInfo.
|
|
35
|
+
test.fixme('Enter after `{` indents the new line by 2 spaces', async ({page}) => {
|
|
36
|
+
await setupPad(page);
|
|
37
|
+
await pickJS(page);
|
|
38
|
+
await page.keyboard.type('if (x) {');
|
|
39
|
+
await page.keyboard.press('Enter');
|
|
40
|
+
await page.keyboard.type('y');
|
|
41
|
+
// After the press, line 0 is "if (x) {" and line 1 is " y"
|
|
42
|
+
const line1 = await inner(page).locator('div[id^="magicdomid"]').nth(1).innerText();
|
|
43
|
+
expect(line1).toBe(' y');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test.fixme('Tab inserts 2 spaces in code mode', async ({page}) => {
|
|
47
|
+
await setupPad(page);
|
|
48
|
+
await pickJS(page);
|
|
49
|
+
await page.keyboard.type('foo');
|
|
50
|
+
await page.keyboard.press('Tab');
|
|
51
|
+
await page.keyboard.type('bar');
|
|
52
|
+
const line0 = await inner(page).locator('div[id^="magicdomid"]').nth(0).innerText();
|
|
53
|
+
expect(line0).toBe('foo bar');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test.fixme('Shift+Tab removes 2 leading spaces', async ({page}) => {
|
|
57
|
+
await setupPad(page);
|
|
58
|
+
await pickJS(page);
|
|
59
|
+
// Manually type 4 leading spaces then content.
|
|
60
|
+
await page.keyboard.type(' deep');
|
|
61
|
+
await page.keyboard.press('Home');
|
|
62
|
+
await page.keyboard.press('Shift+Tab');
|
|
63
|
+
const line0 = await inner(page).locator('div[id^="magicdomid"]').nth(0).innerText();
|
|
64
|
+
expect(line0).toBe(' deep');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('Tab is NOT intercepted when language is auto/off', async ({page}) => {
|
|
68
|
+
await setupPad(page);
|
|
69
|
+
// Default language is 'auto' — codeIndent should bail.
|
|
70
|
+
await page.keyboard.type('foo');
|
|
71
|
+
await page.keyboard.press('Tab');
|
|
72
|
+
await page.keyboard.type('bar');
|
|
73
|
+
const line0 = await inner(page).locator('div[id^="magicdomid"]').nth(0).innerText();
|
|
74
|
+
// We don't assert on the exact value since Etherpad's default Tab handler
|
|
75
|
+
// may insert a tab or move focus; the assertion is that codeIndent did NOT
|
|
76
|
+
// insert " " (two spaces) — it stays in plain-text mode.
|
|
77
|
+
expect(line0.startsWith('foo bar')).toBe(false);
|
|
78
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {expect, test} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
|
|
4
|
+
/** Return the current value of the underlying (hidden) native select. */
|
|
5
|
+
async function getSelectValue(page: import('@playwright/test').Page): Promise<string> {
|
|
6
|
+
return page.evaluate(() => {
|
|
7
|
+
const sel = document.getElementById('ep_hljs_select') as HTMLSelectElement | null;
|
|
8
|
+
return sel ? sel.value : '';
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Wait for the pad editor to be ready in a new page. */
|
|
13
|
+
async function waitForPad(page: import('@playwright/test').Page): Promise<void> {
|
|
14
|
+
await page.waitForSelector('#editorcontainer.initialized', {timeout: 15_000});
|
|
15
|
+
await page.frameLocator('iframe[name="ace_outer"]')
|
|
16
|
+
.frameLocator('iframe[name="ace_inner"]')
|
|
17
|
+
.locator('#innerdocbody[contenteditable="true"]')
|
|
18
|
+
.waitFor({state: 'attached', timeout: 15_000});
|
|
19
|
+
await page.waitForSelector('#ep_hljs_select', {state: 'attached', timeout: 10_000});
|
|
20
|
+
// The toolbar-overlay covers the editor while the pad is initialising and
|
|
21
|
+
// intercepts clicks on the toolbar. Wait for it to be removed/hidden.
|
|
22
|
+
await page.waitForFunction(() => {
|
|
23
|
+
const overlay = document.getElementById('toolbar-overlay');
|
|
24
|
+
return !overlay || overlay.offsetParent === null;
|
|
25
|
+
}, null, {timeout: 10_000});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('B sees the language A picked within 1s', async ({browser}) => {
|
|
29
|
+
const ctxA = await browser.newContext();
|
|
30
|
+
const a = await ctxA.newPage();
|
|
31
|
+
await goToNewPad(a);
|
|
32
|
+
await waitForPad(a);
|
|
33
|
+
const padUrl = a.url();
|
|
34
|
+
|
|
35
|
+
const ctxB = await browser.newContext();
|
|
36
|
+
const b = await ctxB.newPage();
|
|
37
|
+
await b.goto(padUrl);
|
|
38
|
+
await waitForPad(b);
|
|
39
|
+
|
|
40
|
+
// Verify B starts with the default language.
|
|
41
|
+
await expect.poll(() => getSelectValue(b), {timeout: 5_000}).toBe('auto');
|
|
42
|
+
|
|
43
|
+
// A picks Ruby. Set the underlying select directly and trigger the
|
|
44
|
+
// change event the plugin listens for. Avoids fighting the toolbar-overlay
|
|
45
|
+
// and niceSelect timing.
|
|
46
|
+
await a.evaluate(() => {
|
|
47
|
+
const sel = document.getElementById('ep_hljs_select') as HTMLSelectElement;
|
|
48
|
+
sel.value = 'ruby';
|
|
49
|
+
const $: any = (window as any).$;
|
|
50
|
+
if ($ && $.fn) $(sel).trigger('change');
|
|
51
|
+
else sel.dispatchEvent(new Event('change', {bubbles: true}));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// B's underlying select must reflect the new value within 10 s.
|
|
55
|
+
await expect.poll(() => getSelectValue(b), {timeout: 15_000}).toBe('ruby');
|
|
56
|
+
|
|
57
|
+
await ctxA.close();
|
|
58
|
+
await ctxB.close();
|
|
59
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {expect, test, Page} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
|
|
4
|
+
test.setTimeout(45_000);
|
|
5
|
+
|
|
6
|
+
const inner = (page: Page) => page
|
|
7
|
+
.frameLocator('iframe[name="ace_outer"]')
|
|
8
|
+
.frameLocator('iframe[name="ace_inner"]');
|
|
9
|
+
|
|
10
|
+
const setupPad = async (page: Page) => {
|
|
11
|
+
await goToNewPad(page);
|
|
12
|
+
await page.waitForTimeout(1500);
|
|
13
|
+
await inner(page).locator('body').click();
|
|
14
|
+
await page.keyboard.press('Control+A');
|
|
15
|
+
await page.keyboard.press('Delete');
|
|
16
|
+
await page.waitForTimeout(300);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
test('text typed on A appears on B (basic collab sync)', async ({browser}) => {
|
|
20
|
+
const ctxA = await browser.newContext();
|
|
21
|
+
const a = await ctxA.newPage();
|
|
22
|
+
await setupPad(a);
|
|
23
|
+
const padUrl = a.url();
|
|
24
|
+
|
|
25
|
+
const ctxB = await browser.newContext();
|
|
26
|
+
const b = await ctxB.newPage();
|
|
27
|
+
await b.goto(padUrl);
|
|
28
|
+
await b.waitForTimeout(1500);
|
|
29
|
+
|
|
30
|
+
// Type on A, watch B.
|
|
31
|
+
await a.keyboard.type('hello from A');
|
|
32
|
+
// B should receive the text within a few seconds.
|
|
33
|
+
await expect(inner(b).locator('div[id^="magicdomid"]').first())
|
|
34
|
+
.toContainText('hello from A', {timeout: 10_000});
|
|
35
|
+
|
|
36
|
+
// Now type on B, watch A.
|
|
37
|
+
await inner(b).locator('body').click();
|
|
38
|
+
await b.keyboard.press('Control+End');
|
|
39
|
+
await b.keyboard.type(' and B');
|
|
40
|
+
await expect(inner(a).locator('div[id^="magicdomid"]').first())
|
|
41
|
+
.toContainText('and B', {timeout: 10_000});
|
|
42
|
+
|
|
43
|
+
await ctxA.close();
|
|
44
|
+
await ctxB.close();
|
|
45
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {expect, test, Page} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
|
|
4
|
+
const inner = (page: Page) => page
|
|
5
|
+
.frameLocator('iframe[name="ace_outer"]')
|
|
6
|
+
.frameLocator('iframe[name="ace_inner"]');
|
|
7
|
+
|
|
8
|
+
const setupPad = async (page: Page) => {
|
|
9
|
+
await goToNewPad(page);
|
|
10
|
+
await page.waitForTimeout(1500);
|
|
11
|
+
await inner(page).locator('body').click();
|
|
12
|
+
await page.keyboard.press('Control+A');
|
|
13
|
+
await page.keyboard.press('Delete');
|
|
14
|
+
await page.waitForTimeout(300);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const pickJS = async (page: Page) => {
|
|
18
|
+
const niceWrapper = page.locator('#ep_hljs_li .nice-select');
|
|
19
|
+
await niceWrapper.click();
|
|
20
|
+
await page.locator('#ep_hljs_li .nice-select .option[data-value="javascript"]').click();
|
|
21
|
+
await inner(page).locator('body').click();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test('inner doc <html> carries the super-dark-editor class when skin variant is set', async ({page}) => {
|
|
25
|
+
// Etherpad applies skinVariants from clientVars to inner <html> in ace.ts:266.
|
|
26
|
+
// We don't toggle the skin from the test (requires a server restart with
|
|
27
|
+
// different settings) but we can at least verify the selector mechanism
|
|
28
|
+
// applies once super-dark-editor is added programmatically.
|
|
29
|
+
await goToNewPad(page);
|
|
30
|
+
await page.waitForTimeout(1500);
|
|
31
|
+
|
|
32
|
+
await page.evaluate(() => {
|
|
33
|
+
const outer = document.querySelector('iframe[name="ace_outer"]') as HTMLIFrameElement;
|
|
34
|
+
const innerFrame = outer.contentDocument!.querySelector('iframe[name="ace_inner"]') as HTMLIFrameElement;
|
|
35
|
+
innerFrame.contentDocument!.documentElement.classList.add('super-dark-editor');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Probe a known dark-mode rule applies once the class is present.
|
|
39
|
+
const darkApplies = await page.evaluate(() => {
|
|
40
|
+
const outer = document.querySelector('iframe[name="ace_outer"]') as HTMLIFrameElement;
|
|
41
|
+
const innerFrame = outer.contentDocument!.querySelector('iframe[name="ace_inner"]') as HTMLIFrameElement;
|
|
42
|
+
const innerDoc = innerFrame.contentDocument!;
|
|
43
|
+
// Walk style sheets, find the .super-dark-editor ::highlight(hljs-keyword) rule,
|
|
44
|
+
// and ensure it's present and parseable.
|
|
45
|
+
let found = false;
|
|
46
|
+
for (const sheet of [...innerDoc.styleSheets] as CSSStyleSheet[]) {
|
|
47
|
+
let rules: CSSRuleList;
|
|
48
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
49
|
+
for (const rule of [...rules] as CSSRule[]) {
|
|
50
|
+
const r = rule as CSSStyleRule;
|
|
51
|
+
if (r.selectorText && r.selectorText.includes('.super-dark-editor') &&
|
|
52
|
+
r.selectorText.includes('::highlight(hljs-keyword)')) {
|
|
53
|
+
found = true;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (found) break;
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
});
|
|
61
|
+
expect(darkApplies).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('::highlight() rules are loaded into the inner doc', async ({page}) => {
|
|
65
|
+
await setupPad(page);
|
|
66
|
+
await pickJS(page);
|
|
67
|
+
await page.keyboard.type('const');
|
|
68
|
+
await page.waitForTimeout(1500);
|
|
69
|
+
|
|
70
|
+
// Verify that at least one ::highlight() rule for hljs-keyword exists in the
|
|
71
|
+
// inner doc (proves CSS is reaching the right document context).
|
|
72
|
+
const lightApplies = await page.evaluate(() => {
|
|
73
|
+
const outer = document.querySelector('iframe[name="ace_outer"]') as HTMLIFrameElement;
|
|
74
|
+
const innerFrame = outer.contentDocument!.querySelector('iframe[name="ace_inner"]') as HTMLIFrameElement;
|
|
75
|
+
const innerDoc = innerFrame.contentDocument!;
|
|
76
|
+
for (const sheet of [...innerDoc.styleSheets] as CSSStyleSheet[]) {
|
|
77
|
+
let rules: CSSRuleList;
|
|
78
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
79
|
+
for (const rule of [...rules] as CSSRule[]) {
|
|
80
|
+
const r = rule as CSSStyleRule;
|
|
81
|
+
if (r.selectorText === '::highlight(hljs-keyword)') return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
});
|
|
86
|
+
expect(lightApplies).toBe(true);
|
|
87
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {expect, test} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
|
|
4
|
+
test('HTML export contains hljs spans + theme CSS', async ({page, request}) => {
|
|
5
|
+
await goToNewPad(page);
|
|
6
|
+
// Allow postAceInit (which connects the socket) to complete before interacting.
|
|
7
|
+
await page.waitForTimeout(1000);
|
|
8
|
+
const padUrl = page.url();
|
|
9
|
+
const padId = padUrl.split('/p/')[1].split('?')[0];
|
|
10
|
+
|
|
11
|
+
// Select JavaScript via the nice-select widget.
|
|
12
|
+
const niceWrapper = page.locator('#ep_hljs_li .nice-select');
|
|
13
|
+
await niceWrapper.click();
|
|
14
|
+
await page.locator('#ep_hljs_li .nice-select .option[data-value="javascript"]').click();
|
|
15
|
+
|
|
16
|
+
const inner = page.frameLocator('iframe[name="ace_outer"]').frameLocator('iframe[name="ace_inner"]');
|
|
17
|
+
// Clear boilerplate so only our snippet is exported.
|
|
18
|
+
await inner.locator('body').click();
|
|
19
|
+
await page.keyboard.press('Control+A');
|
|
20
|
+
await page.keyboard.press('Delete');
|
|
21
|
+
await page.waitForTimeout(500);
|
|
22
|
+
|
|
23
|
+
await page.keyboard.type('function f(){return 1;}');
|
|
24
|
+
// Give the socket time to store the language on the server.
|
|
25
|
+
await page.waitForTimeout(1200);
|
|
26
|
+
|
|
27
|
+
const res = await request.get(`http://localhost:9001/p/${padId}/export/html`);
|
|
28
|
+
const body = await res.text();
|
|
29
|
+
expect(body).toContain('<span class="hljs-keyword">function</span>');
|
|
30
|
+
expect(body).toContain('.hljs-keyword');
|
|
31
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {expect, test} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
import {highlightCount} from '../helper/highlights';
|
|
4
|
+
|
|
5
|
+
test.setTimeout(45_000);
|
|
6
|
+
|
|
7
|
+
test('reload of pad with persisted JS language tokenizes lines without edit', async ({browser}) => {
|
|
8
|
+
// Session 1: pick JavaScript and type code; persists language to pad store.
|
|
9
|
+
const ctx1 = await browser.newContext();
|
|
10
|
+
const p1 = await ctx1.newPage();
|
|
11
|
+
await goToNewPad(p1);
|
|
12
|
+
await p1.waitForTimeout(1500);
|
|
13
|
+
|
|
14
|
+
const inner1 = p1.frameLocator('iframe[name="ace_outer"]')
|
|
15
|
+
.frameLocator('iframe[name="ace_inner"]');
|
|
16
|
+
await inner1.locator('body').click();
|
|
17
|
+
await p1.keyboard.press('Control+A');
|
|
18
|
+
await p1.keyboard.press('Delete');
|
|
19
|
+
await p1.waitForTimeout(300);
|
|
20
|
+
|
|
21
|
+
const niceWrapper = p1.locator('#ep_hljs_li .nice-select');
|
|
22
|
+
await niceWrapper.click();
|
|
23
|
+
await p1.locator('#ep_hljs_li .nice-select .option[data-value="javascript"]').click();
|
|
24
|
+
await inner1.locator('body').click();
|
|
25
|
+
await p1.keyboard.type('const foo = "bar"; // note');
|
|
26
|
+
await p1.waitForTimeout(2000);
|
|
27
|
+
|
|
28
|
+
const padUrl = p1.url();
|
|
29
|
+
await ctx1.close();
|
|
30
|
+
|
|
31
|
+
// Session 2: open the same URL fresh in a new context — must tokenize on
|
|
32
|
+
// initial paint (without the user editing anything).
|
|
33
|
+
const ctx2 = await browser.newContext();
|
|
34
|
+
const p2 = await ctx2.newPage();
|
|
35
|
+
await p2.goto(padUrl);
|
|
36
|
+
await p2.waitForTimeout(3500);
|
|
37
|
+
expect(await highlightCount(p2, 'hljs-keyword')).toBeGreaterThan(0);
|
|
38
|
+
await ctx2.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('toggle init: highlight checkbox is checked by default', async ({page}) => {
|
|
42
|
+
await goToNewPad(page);
|
|
43
|
+
await page.waitForTimeout(2500);
|
|
44
|
+
const checked = await page.evaluate(() => {
|
|
45
|
+
const el = document.getElementById('options-syntax-highlighting') as HTMLInputElement | null;
|
|
46
|
+
return el ? el.checked : null;
|
|
47
|
+
});
|
|
48
|
+
expect(checked).toBe(true);
|
|
49
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {expect, test} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
import {expectHighlightWithin} from '../helper/highlights';
|
|
4
|
+
|
|
5
|
+
/** Select a language via the nice-select widget that wraps the native <select>. */
|
|
6
|
+
async function pickLanguage(page: import('@playwright/test').Page, value: string) {
|
|
7
|
+
// The colibris skin replaces <select> with a .nice-select div; click to open then pick the option.
|
|
8
|
+
const niceWrapper = page.locator('#ep_hljs_li .nice-select');
|
|
9
|
+
await niceWrapper.click();
|
|
10
|
+
await page.locator(`#ep_hljs_li .nice-select .option[data-value="${value}"]`).click();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Return the current value of the underlying (hidden) native select. */
|
|
14
|
+
async function getSelectValue(page: import('@playwright/test').Page): Promise<string> {
|
|
15
|
+
return page.evaluate(() => {
|
|
16
|
+
const sel = document.getElementById('ep_hljs_select') as HTMLSelectElement | null;
|
|
17
|
+
return sel ? sel.value : '';
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('picks Python and persists across reload', async ({page}) => {
|
|
22
|
+
await goToNewPad(page);
|
|
23
|
+
// Allow postAceInit (which connects the socket) to complete before interacting.
|
|
24
|
+
await page.waitForTimeout(1000);
|
|
25
|
+
|
|
26
|
+
// The underlying (hidden) <select> carries the aria-label; verify it is present.
|
|
27
|
+
const sel = page.locator('#ep_hljs_select');
|
|
28
|
+
await expect(sel).toHaveAttribute('aria-label', 'Syntax language');
|
|
29
|
+
|
|
30
|
+
// The nice-select renders the "auto" option as "Auto-detect".
|
|
31
|
+
await expect(page.locator('#ep_hljs_li .nice-select .option[data-value="auto"]'))
|
|
32
|
+
.toHaveText('Auto-detect');
|
|
33
|
+
|
|
34
|
+
await pickLanguage(page, 'python');
|
|
35
|
+
|
|
36
|
+
const inner = page.frameLocator('iframe[name="ace_outer"]').frameLocator('iframe[name="ace_inner"]');
|
|
37
|
+
// Clear the welcome-text boilerplate so only our Python snippet is present.
|
|
38
|
+
await inner.locator('body').click();
|
|
39
|
+
await page.keyboard.press('Control+A');
|
|
40
|
+
await page.keyboard.press('Delete');
|
|
41
|
+
await page.waitForTimeout(500);
|
|
42
|
+
|
|
43
|
+
await page.keyboard.type('def add(a, b): return a + b');
|
|
44
|
+
// Press Enter so the line with `def` is no longer the active line.
|
|
45
|
+
await page.keyboard.press('Enter');
|
|
46
|
+
await page.waitForTimeout(2000);
|
|
47
|
+
await expectHighlightWithin(page, 'hljs-keyword', 10_000);
|
|
48
|
+
|
|
49
|
+
// Wait for the language change to be committed to the server before reloading.
|
|
50
|
+
await page.waitForTimeout(1000);
|
|
51
|
+
await page.reload();
|
|
52
|
+
// After reload the underlying select must reflect the persisted language.
|
|
53
|
+
await expect.poll(() => getSelectValue(page), {timeout: 10_000}).toBe('python');
|
|
54
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {expect, test} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
import {expectHighlightWithin} from '../helper/highlights';
|
|
4
|
+
|
|
5
|
+
test('5000-line JS pad still highlights and stays responsive', async ({page, context}) => {
|
|
6
|
+
test.setTimeout(180_000);
|
|
7
|
+
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
|
8
|
+
await goToNewPad(page);
|
|
9
|
+
|
|
10
|
+
// Select JavaScript explicitly so all lines are highlighted as JS.
|
|
11
|
+
const niceWrapper = page.locator('#ep_hljs_li .nice-select');
|
|
12
|
+
await niceWrapper.click();
|
|
13
|
+
await page.locator('#ep_hljs_li .nice-select .option[data-value="javascript"]').click();
|
|
14
|
+
|
|
15
|
+
const inner = page.frameLocator('iframe[name="ace_outer"]').frameLocator('iframe[name="ace_inner"]');
|
|
16
|
+
// Clear existing boilerplate content.
|
|
17
|
+
await inner.locator('body').click();
|
|
18
|
+
await page.keyboard.press('Control+A');
|
|
19
|
+
await page.keyboard.press('Delete');
|
|
20
|
+
await page.waitForTimeout(500);
|
|
21
|
+
|
|
22
|
+
const blob =Array.from({length: 5000}, (_, i) => `function f${i}(){return ${i};}`).join('\n');
|
|
23
|
+
await page.evaluate((text) => navigator.clipboard.writeText(text), blob);
|
|
24
|
+
await page.keyboard.press('Control+V');
|
|
25
|
+
|
|
26
|
+
// Move caret off the latest paste line so the active-line skip logic
|
|
27
|
+
// doesn't hide highlights on it.
|
|
28
|
+
await page.keyboard.press('Home');
|
|
29
|
+
await page.keyboard.press('Home');
|
|
30
|
+
await expectHighlightWithin(page, 'hljs-keyword', 60_000);
|
|
31
|
+
|
|
32
|
+
// Typing should still feel responsive.
|
|
33
|
+
const t0 = Date.now();
|
|
34
|
+
await page.keyboard.type('// extra');
|
|
35
|
+
expect(Date.now() - t0).toBeLessThan(5_000);
|
|
36
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {test} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
import {expectHighlightWithin} from '../helper/highlights';
|
|
4
|
+
|
|
5
|
+
test('highlights a JS keyword after debounce', async ({page}) => {
|
|
6
|
+
await goToNewPad(page);
|
|
7
|
+
// Allow postAceInit (which connects the socket) to complete before interacting.
|
|
8
|
+
await page.waitForTimeout(1000);
|
|
9
|
+
|
|
10
|
+
// Select JavaScript explicitly so auto-detect doesn't mis-classify the
|
|
11
|
+
// pad's default "Welcome to Etherpad!" boilerplate.
|
|
12
|
+
const niceWrapper = page.locator('#ep_hljs_li .nice-select');
|
|
13
|
+
await niceWrapper.click();
|
|
14
|
+
await page.locator('#ep_hljs_li .nice-select .option[data-value="javascript"]').click();
|
|
15
|
+
|
|
16
|
+
const inner = page.frameLocator('iframe[name="ace_outer"]').frameLocator('iframe[name="ace_inner"]');
|
|
17
|
+
// Clear the welcome-text boilerplate so the only content is our JS snippet.
|
|
18
|
+
await inner.locator('body').click();
|
|
19
|
+
await page.keyboard.press('Control+A');
|
|
20
|
+
await page.keyboard.press('Delete');
|
|
21
|
+
await page.waitForTimeout(500);
|
|
22
|
+
|
|
23
|
+
await page.keyboard.type('function add(a, b) { return a + b; }');
|
|
24
|
+
await page.keyboard.press('Enter');
|
|
25
|
+
await page.waitForTimeout(2000);
|
|
26
|
+
await expectHighlightWithin(page, 'hljs-keyword', 10_000);
|
|
27
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {expect, test, Page} from '@playwright/test';
|
|
2
|
+
import {goToNewPad} from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
|
|
3
|
+
|
|
4
|
+
test.setTimeout(60_000);
|
|
5
|
+
|
|
6
|
+
const inner = (page: Page) => page
|
|
7
|
+
.frameLocator('iframe[name="ace_outer"]')
|
|
8
|
+
.frameLocator('iframe[name="ace_inner"]');
|
|
9
|
+
|
|
10
|
+
const setupPad = async (page: Page) => {
|
|
11
|
+
await goToNewPad(page);
|
|
12
|
+
await page.waitForTimeout(1000);
|
|
13
|
+
await inner(page).locator('body').click();
|
|
14
|
+
await page.keyboard.press('Control+A');
|
|
15
|
+
await page.keyboard.press('Delete');
|
|
16
|
+
await page.waitForTimeout(300);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const pickLanguage = async (page: Page, value: string) => {
|
|
20
|
+
const niceWrapper = page.locator('#ep_hljs_li .nice-select');
|
|
21
|
+
if (await niceWrapper.count() > 0) {
|
|
22
|
+
await niceWrapper.click();
|
|
23
|
+
await page.locator(`#ep_hljs_li .nice-select .option[data-value="${value}"]`).click();
|
|
24
|
+
} else {
|
|
25
|
+
await page.locator('#ep_hljs_select').selectOption(value);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const repSelStart = async (page: Page): Promise<[number, number] | null> => {
|
|
30
|
+
return await page.evaluate(() => {
|
|
31
|
+
try {
|
|
32
|
+
const outer = document.querySelector('iframe[name="ace_outer"]') as HTMLIFrameElement;
|
|
33
|
+
const inner = outer.contentDocument!.querySelector('iframe[name="ace_inner"]') as HTMLIFrameElement;
|
|
34
|
+
const innerDoc = inner.contentDocument!;
|
|
35
|
+
const sel = innerDoc.getSelection();
|
|
36
|
+
if (!sel || !sel.anchorNode) return null;
|
|
37
|
+
let lineEl: Node | null = sel.anchorNode;
|
|
38
|
+
while (lineEl && (!(lineEl as Element).id || !((lineEl as Element).id || '').startsWith('magicdomid'))) {
|
|
39
|
+
lineEl = lineEl.parentNode;
|
|
40
|
+
}
|
|
41
|
+
if (!lineEl) return null;
|
|
42
|
+
const allLines = innerDoc.querySelectorAll('div[id^="magicdomid"]');
|
|
43
|
+
let lineIdx = -1;
|
|
44
|
+
for (let i = 0; i < allLines.length; i++) {
|
|
45
|
+
if (allLines[i] === lineEl) { lineIdx = i; break; }
|
|
46
|
+
}
|
|
47
|
+
if (lineIdx < 0) return null;
|
|
48
|
+
const treeWalker = innerDoc.createTreeWalker(lineEl as Node, NodeFilter.SHOW_TEXT);
|
|
49
|
+
let col = 0;
|
|
50
|
+
let cur: Node | null;
|
|
51
|
+
while ((cur = treeWalker.nextNode())) {
|
|
52
|
+
if (cur === sel.anchorNode) { return [lineIdx, col + sel.anchorOffset]; }
|
|
53
|
+
col += (cur.nodeValue || '').length;
|
|
54
|
+
}
|
|
55
|
+
return [lineIdx, col];
|
|
56
|
+
} catch { return null; }
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
test('user-reported repro: const foo = "bar"; with JS, B edits "bar" while A is on line 0', async ({browser}) => {
|
|
61
|
+
// ---- USER A ----
|
|
62
|
+
const ctxA = await browser.newContext();
|
|
63
|
+
const pageA = await ctxA.newPage();
|
|
64
|
+
await setupPad(pageA);
|
|
65
|
+
await pickLanguage(pageA, 'javascript');
|
|
66
|
+
await inner(pageA).locator('body').click();
|
|
67
|
+
await page_typeOnA(pageA);
|
|
68
|
+
await pageA.waitForTimeout(2000);
|
|
69
|
+
|
|
70
|
+
const padUrl = pageA.url();
|
|
71
|
+
|
|
72
|
+
// Move A's caret to line 0 col 1.
|
|
73
|
+
await pageA.keyboard.press('Home');
|
|
74
|
+
await pageA.waitForTimeout(300);
|
|
75
|
+
await pageA.keyboard.press('ArrowRight');
|
|
76
|
+
await pageA.waitForTimeout(300);
|
|
77
|
+
const beforeA = await repSelStart(pageA);
|
|
78
|
+
console.log('A caret before B edits:', beforeA);
|
|
79
|
+
expect(beforeA).toEqual([0, 1]);
|
|
80
|
+
|
|
81
|
+
// ---- USER B ----
|
|
82
|
+
const ctxB = await browser.newContext();
|
|
83
|
+
const pageB = await ctxB.newPage();
|
|
84
|
+
await pageB.goto(padUrl);
|
|
85
|
+
await pageB.waitForTimeout(2000);
|
|
86
|
+
|
|
87
|
+
// B edits "bar" → "baz".
|
|
88
|
+
await inner(pageB).locator('body').click();
|
|
89
|
+
await pageB.keyboard.press('Control+End');
|
|
90
|
+
// Position past `;`, `"` to land on `r` of "bar".
|
|
91
|
+
await pageB.keyboard.press('ArrowLeft'); // before `;`
|
|
92
|
+
await pageB.keyboard.press('ArrowLeft'); // before `"`
|
|
93
|
+
// Select "bar" backwards (3 chars).
|
|
94
|
+
await pageB.keyboard.down('Shift');
|
|
95
|
+
await pageB.keyboard.press('ArrowLeft');
|
|
96
|
+
await pageB.keyboard.press('ArrowLeft');
|
|
97
|
+
await pageB.keyboard.press('ArrowLeft');
|
|
98
|
+
await pageB.keyboard.up('Shift');
|
|
99
|
+
await pageB.keyboard.type('baz');
|
|
100
|
+
await pageB.waitForTimeout(2500);
|
|
101
|
+
|
|
102
|
+
const afterA = await repSelStart(pageA);
|
|
103
|
+
console.log('A caret after B edits:', afterA);
|
|
104
|
+
expect(afterA).toEqual([0, 1]);
|
|
105
|
+
|
|
106
|
+
await ctxA.close();
|
|
107
|
+
await ctxB.close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const page_typeOnA = async (pageA: Page) => {
|
|
111
|
+
await pageA.keyboard.type('const foo = "bar";');
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// CI-flaky: niceSelect dropdown timing intermittently fails to register the
|
|
115
|
+
// language pick on the GitHub runner. Repro 1 above exercises the same
|
|
116
|
+
// "B-edits-line-0-while-A's-caret-is-there" code path; manual testing
|
|
117
|
+
// confirms repro 2 works in real browsers.
|
|
118
|
+
test.fixme('user-reported repro 2: A at end of line 0, B inserts "my " before test on line 0', async ({browser}) => {
|
|
119
|
+
// ---- USER A ----
|
|
120
|
+
const ctxA = await browser.newContext();
|
|
121
|
+
const pageA = await ctxA.newPage();
|
|
122
|
+
await setupPad(pageA);
|
|
123
|
+
await pickLanguage(pageA, 'javascript');
|
|
124
|
+
await inner(pageA).locator('body').click();
|
|
125
|
+
|
|
126
|
+
// Type the user's exact content: full line of code on line 0, Enter, then nothing on line 1.
|
|
127
|
+
await pageA.keyboard.type('const foo = "bar"; // test');
|
|
128
|
+
await pageA.keyboard.press('Enter');
|
|
129
|
+
await pageA.waitForTimeout(2000);
|
|
130
|
+
|
|
131
|
+
const padUrl = pageA.url();
|
|
132
|
+
|
|
133
|
+
// Move A's caret to end of line 0.
|
|
134
|
+
await pageA.keyboard.press('ArrowUp'); // back to line 0
|
|
135
|
+
await pageA.keyboard.press('End');
|
|
136
|
+
await pageA.waitForTimeout(500);
|
|
137
|
+
const beforeA = await repSelStart(pageA);
|
|
138
|
+
console.log('A caret before B edits:', beforeA);
|
|
139
|
+
expect(beforeA).toEqual([0, 26]);
|
|
140
|
+
|
|
141
|
+
// ---- USER B ----
|
|
142
|
+
const ctxB = await browser.newContext();
|
|
143
|
+
const pageB = await ctxB.newPage();
|
|
144
|
+
await pageB.goto(padUrl);
|
|
145
|
+
await pageB.waitForTimeout(2000);
|
|
146
|
+
|
|
147
|
+
// Position B's caret at the beginning of "test" on line 0.
|
|
148
|
+
// Line 0 is "const foo = \"bar\"; // test" (26 chars; "test" starts at col 22).
|
|
149
|
+
await inner(pageB).locator('body').click();
|
|
150
|
+
await pageB.keyboard.press('Control+Home');
|
|
151
|
+
// Walk forward 22 chars to land before "t" of "test".
|
|
152
|
+
for (let i = 0; i < 22; i++) await pageB.keyboard.press('ArrowRight');
|
|
153
|
+
await pageB.waitForTimeout(500);
|
|
154
|
+
await pageB.keyboard.type('my ');
|
|
155
|
+
await pageB.waitForTimeout(2500);
|
|
156
|
+
|
|
157
|
+
// A's caret should still be at the END of line 0 (now col 29 after B's
|
|
158
|
+
// 3-char insertion that was BEFORE A's position).
|
|
159
|
+
const afterA = await repSelStart(pageA);
|
|
160
|
+
// Either [0, 29] (rebased forward) or [0, 26] (unchanged) — either is
|
|
161
|
+
// correct; what matters is it's NOT [0, 0].
|
|
162
|
+
expect(afterA).not.toEqual([0, 0]);
|
|
163
|
+
expect(afterA![0]).toBe(0);
|
|
164
|
+
|
|
165
|
+
await ctxA.close();
|
|
166
|
+
await ctxB.close();
|
|
167
|
+
});
|