Ported more tests to playwright. (#6214)

* Ported more tests to playwright.

* Added language test.

* Fixed failing tests. Moved another test.

* Removed frontend tests.

* Splitted runners.

* Fixed runners.

* Split up into the different browser environments.

* Added github reporter.

* Added change user color test.
This commit is contained in:
SamTV12345 2024-03-12 17:45:47 +01:00 committed by GitHub
parent 19ee8c2afa
commit 078324c0d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1702 additions and 1229 deletions

View file

@ -7,11 +7,9 @@ permissions:
contents: read # to fetch code (actions/checkout)
jobs:
withoutplugins:
name: without plugins
playwright-chrome:
name: Playwright Chrome
runs-on: ubuntu-latest
if: ${{ github.actor != 'dependabot[bot]' }}
steps:
-
name: Generate Sauce Labs strings
@ -37,6 +35,7 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
if: always()
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
@ -53,79 +52,7 @@ jobs:
run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
-
name: Create settings.json
run: cp settings.json.template settings.json
-
name: Disable import/export rate limiting
run: |
sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json
-
uses: saucelabs/sauce-connect-action@v2.3.6
with:
username: ${{ secrets.SAUCE_USERNAME }}
accessKey: ${{ secrets.SAUCE_ACCESS_KEY }}
tunnelIdentifier: ${{ steps.sauce_strings.outputs.tunnel_id }}
-
name: Run the frontend tests
shell: bash
env:
SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
SAUCE_NAME: ${{ steps.sauce_strings.outputs.name }}
TRAVIS_JOB_NUMBER: ${{ steps.sauce_strings.outputs.tunnel_id }}
GIT_HASH: ${{ steps.environment.outputs.sha_short }}
run: |
src/tests/frontend/travis/runner.sh
withoutpluginsPlaywright:
name: without plugins
runs-on: ubuntu-latest
if: ${{ github.actor != 'dependabot[bot]' }}
steps:
-
name: Generate Sauce Labs strings
id: sauce_strings
run: |
printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}'
printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'
-
name: Checkout repository
uses: actions/checkout@v4
-
uses: actions/setup-node@v4
with:
node-version: 21
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Only install direct dependencies
run: pnpm config set auto-install-peers false
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installDeps.sh
-
name: export GIT_HASH to env
id: environment
run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
-
name: Create settings.json
run: cp settings.json.template settings.json
-
name: Disable import/export rate limiting
run: |
sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json
run: cp ./src/tests/settings.json settings.json
- name: Run the frontend tests
shell: bash
run: |
@ -141,20 +68,80 @@ jobs:
sleep 1
done
cd src
pnpm exec playwright install
pnpm exec playwright install-deps
pnpm run test-ui
pnpm exec playwright install chromium --with-deps
pnpm run test-ui --project=chromium
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.node }}
name: playwright-report-${{ matrix.node }}-chrome
path: src/playwright-report/
retention-days: 30
withplugins:
name: with plugins
playwright-firefox:
name: Playwright Firefox
runs-on: ubuntu-latest
steps:
- name: Generate Sauce Labs strings
id: sauce_strings
run: |
printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}'
printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 21
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
version: 8
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
if: always()
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Only install direct dependencies
run: pnpm config set auto-install-peers false
- name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installDeps.sh
- name: export GIT_HASH to env
id: environment
run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
- name: Create settings.json
run: cp ./src/tests/settings.json settings.json
- name: Run the frontend tests
shell: bash
run: |
pnpm run dev &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
connected=true
}
now() { date +%s; }
start=$(now)
while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do
sleep 1
done
cd src
pnpm exec playwright install firefox --with-deps
pnpm run test-ui --project=firefox
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.node }}-firefox
path: src/playwright-report/
retention-days: 30
playwright-webkit:
name: Playwright Webkit
runs-on: ubuntu-latest
if: ${{ github.actor != 'dependabot[bot]' }}
steps:
-
@ -169,7 +156,7 @@ jobs:
-
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 21
- uses: pnpm/action-setup@v3
name: Install pnpm
with:
@ -181,40 +168,14 @@ jobs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
if: always()
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
-
name: Install Etherpad plugins
# The --legacy-peer-deps flag is required to work around a bug in npm v7:
# https://github.com/npm/cli/issues/2199
run: >
pnpm install --workspace-root
ep_align
ep_author_hover
ep_cursortrace
ep_embedmedia
ep_font_size
ep_hash_auth
ep_headings2
ep_image_upload
ep_markdown
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents
# Etherpad core dependencies must be installed after installing the
# plugin's dependencies, otherwise npm will try to hoist common
# dependencies by removing them from src/node_modules and installing them
# in the top-level node_modules. As of v6.20.10, npm's hoist logic appears
# to be buggy, because it sometimes removes dependencies from
# src/node_modules but fails to add them to the top-level node_modules.
# Even if npm correctly hoists the dependencies, the hoisting seems to
# confuse tools such as `npm outdated`, `npm update`, and some ESLint
# rules.
- name: Only install direct dependencies
run: pnpm config set auto-install-peers false
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: bin/installDeps.sh
@ -224,31 +185,30 @@ jobs:
run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
-
name: Create settings.json
run: cp settings.json.template settings.json
-
name: Disable import/export rate limiting
run: |
sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 1000000/' -i settings.json
# XXX we should probably run all tests, because plugins could effect their results
-
name: Remove standard frontend test files, so only plugin tests are run
run: rm src/tests/frontend/specs/*
-
uses: saucelabs/sauce-connect-action@v2.3.6
with:
username: ${{ secrets.SAUCE_USERNAME }}
accessKey: ${{ secrets.SAUCE_ACCESS_KEY }}
tunnelIdentifier: ${{ steps.sauce_strings.outputs.tunnel_id }}
-
name: Run the frontend tests
run: cp ./src/tests/settings.json settings.json
- name: Run the frontend tests
shell: bash
env:
SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
SAUCE_NAME: ${{ steps.sauce_strings.outputs.name }}
TRAVIS_JOB_NUMBER: ${{ steps.sauce_strings.outputs.tunnel_id }}
GIT_HASH: ${{ steps.environment.outputs.sha_short }}
run: |
src/tests/frontend/travis/runner.sh
pnpm run dev &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
connected=true
}
now() { date +%s; }
start=$(now)
while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do
sleep 1
done
cd src
pnpm exec playwright install webkit --with-deps
pnpm run test-ui --project=webkit
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.node }}-webkit
path: src/playwright-report/
retention-days: 30

View file

@ -96,7 +96,6 @@
"nodeify": "^1.0.1",
"openapi-schema-validation": "^0.4.2",
"@playwright/test": "^1.42.1",
"selenium-webdriver": "^4.18.1",
"set-cookie-parser": "^2.6.0",
"sinon": "^17.0.1",
"split-grid": "^1.0.11",

View file

@ -1,18 +1,23 @@
import {defineConfig, devices, test} from '@playwright/test';
export const defaultExpectTimeout = process.env.CI ? 20 * 1000 : 5000
export const defaultTestTimeout = 90 * 1000
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests/frontend-new/',
timeout: 90000,
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
retries: 3,
reporter: process.env.CI ? 'github' : 'html',
expect: { timeout: defaultExpectTimeout },
timeout: defaultTestTimeout,
retries: 2,
workers: 20,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
@ -20,6 +25,7 @@ export default defineConfig({
baseURL: "localhost:9001",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
video: 'on-first-retry',
},
/* Configure projects for major browsers */
@ -33,7 +39,11 @@ export default defineConfig({
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'chrome-firefox',
use:
{...devices['Desktop Firefox'], ...devices['Desktop Chrome']},
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },

View file

@ -1,6 +1,6 @@
import {Frame, Locator, Page} from "@playwright/test";
import {MapArrayType} from "../../../node/types/MapType";
import {randomInt} from "node:crypto";
import {randomUUID} from "node:crypto";
export const getPadOuter = async (page: Page): Promise<Frame> => {
return page.frame('ace_outer')!;
@ -115,7 +115,7 @@ export const appendQueryParams = async (page: Page, queryParameters: MapArrayTyp
export const goToNewPad = async (page: Page) => {
// create a new pad before each test run
const padId = "FRONTEND_TESTS"+randomInt(0, 1000);
const padId = "FRONTEND_TESTS"+randomUUID();
await page.goto('http://localhost:9001/p/'+padId);
await page.waitForSelector('iframe[name="ace_outer"]');
return padId;
@ -128,6 +128,8 @@ export const goToPad = async (page: Page, padId: string) => {
export const clearPadContent = async (page: Page) => {
const body = await getPadBody(page);
await body.click();
await page.keyboard.down('Control');
await page.keyboard.press('A');
await page.keyboard.up('Control');

View file

@ -0,0 +1,20 @@
import {Page} from "@playwright/test";
/**
* Sets the src-attribute of the main iframe to the timeslider
* In case a revision is given, sets the timeslider to this specific revision.
* Defaults to going to the last revision.
* It waits until the timer is filled with date and time, because it's one of the
* last things that happen during timeslider load
*
* @param page
* @param {number} [revision] the optional revision
* @returns {Promise}
* @todo for some reason this does only work the first time, you cannot
* goto rev 0 and then via the same method to rev 5. Use buttons instead
*/
export const gotoTimeslider = async (page: Page, revision: number): Promise<any> => {
let revisionString = Number.isInteger(revision) ? `#${revision}` : '';
await page.goto(`${page.url()}/timeslider${revisionString}`);
await page.waitForSelector('#timer')
};

View file

@ -0,0 +1,103 @@
import {expect, test} from "@playwright/test";
import {goToNewPad, sendChatMessage, showChat} from "../helper/padHelper";
test.beforeEach(async ({page}) => {
await goToNewPad(page);
})
test.describe('change user color', function () {
test('Color picker matches original color and remembers the user color after a refresh',
async function ({page}) {
// click on the settings button to make settings visible
let $userButton = page.locator('.buttonicon-showusers');
await $userButton.click()
let $userSwatch = page.locator('#myswatch');
await $userSwatch.click()
// Change the color value of the Farbtastic color picker
const $colorPickerSave = page.locator('#mycolorpickersave');
let $colorPickerPreview = page.locator('#mycolorpickerpreview');
// Same color represented in two different ways
const testColorHash = '#abcdef';
const testColorRGB = 'rgb(171, 205, 239)';
// Check that the color picker matches the automatically assigned random color on the swatch.
// NOTE: This has a tiny chance of creating a false positive for passing in the
// off-chance the randomly assigned color is the same as the test color.
expect(await $colorPickerPreview.getAttribute('style')).toContain(await $userSwatch.getAttribute('style'));
// The swatch updates as the test color is picked.
await page.evaluate((testRGBColor) => {
document.getElementById('mycolorpickerpreview')!.style.backgroundColor = testRGBColor;
}, testColorRGB
)
await $colorPickerSave.click();
// give it a second to save the color on the server side
await page.waitForTimeout(1000)
// get a new pad, but don't clear the cookies
await goToNewPad(page)
// click on the settings button to make settings visible
await $userButton.click()
await $userSwatch.click()
expect(await $colorPickerPreview.getAttribute('style')).toContain(await $userSwatch.getAttribute('style'));
});
test('Own user color is shown when you enter a chat', async function ({page}) {
const colorOption = page.locator('#options-colorscheck');
if (!(await colorOption.isChecked())) {
await colorOption.check();
}
// click on the settings button to make settings visible
const $userButton = page.locator('.buttonicon-showusers');
await $userButton.click()
const $userSwatch = page.locator('#myswatch');
await $userSwatch.click()
const $colorPickerSave = page.locator('#mycolorpickersave');
// Same color represented in two different ways
const testColorHash = '#abcdef';
const testColorRGB = 'rgb(171, 205, 239)';
// The swatch updates as the test color is picked.
await page.evaluate((testRGBColor) => {
document.getElementById('mycolorpickerpreview')!.style.backgroundColor = testRGBColor;
}, testColorRGB
)
await $colorPickerSave.click();
// click on the chat button to make chat visible
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();
expect(chatText).toContain('O hi');
const color = await chatP.evaluate((el) => {
return window.getComputedStyle(el).getPropertyValue('background-color');
}, chatText);
expect(color).toBe(testColorRGB);
});
});

View file

@ -63,8 +63,7 @@ test.describe('embed links', function () {
// create a new pad before each test run
await goToNewPad(page);
})
test.describe('the share link', function () {
test('is the actual pad url', async function ({page}) {
test('the share link is the actual pad url', async function ({page}) {
const shareButton = page.locator('.buttonicon-embed')
// open share dropdown
@ -75,10 +74,8 @@ test.describe('embed links', function () {
const padURL = page.url();
expect(shareLink).toBe(padURL);
});
});
test.describe('the embed as iframe code', function () {
test('is an iframe with the the correct url parameters and correct size', async function ({page}) {
test('is an iframe with the the correct url parameters and correct size', async function ({page}) {
const shareButton = page.locator('.buttonicon-embed')
await shareButton.click()
@ -89,7 +86,6 @@ test.describe('embed links', function () {
await checkiFrameCode(embedCode, false, page);
});
});
});
test.describe('when read only option is set', function () {
@ -98,8 +94,7 @@ test.describe('embed links', function () {
await goToNewPad(page);
})
test.describe('the share link', function () {
test('shows a read only url', async function ({page}) {
test('the share link shows a read only url', async function ({page}) {
// open share dropdown
const shareButton = page.locator('.buttonicon-embed')
@ -115,10 +110,8 @@ test.describe('embed links', function () {
const containsReadOnlyLink = shareLink.indexOf('r.') > 0;
expect(containsReadOnlyLink).toBe(true);
});
});
test.describe('the embed as iframe code', function () {
test('is an iframe with the the correct url parameters and correct size', async function ({page}) {
test('the embed as iframe code is an iframe with the the correct url parameters and correct size', async function ({page}) {
// open share dropdown
@ -139,8 +132,5 @@ test.describe('embed links', function () {
await checkiFrameCode(embedCode, true, page);
});
});
})
})

View file

@ -0,0 +1,39 @@
import {expect, test} from "@playwright/test";
import {getPadBody, goToNewPad} from "../helper/padHelper";
import {showSettings} from "../helper/settingsHelper";
test.beforeEach(async ({ page })=>{
// create a new pad before each test run
await goToNewPad(page);
})
test.describe('font select', function () {
// create a new pad before each test run
test('makes text RobotoMono', async function ({page}) {
// click on the settings button to make settings visible
await showSettings(page);
// get the font menu and RobotoMono option
const viewFontMenu = page.locator('#viewfontmenu');
// select RobotoMono and fire change event
// $RobotoMonooption.attr('selected','selected');
// commenting out above will break safari test
const dropdown = page.locator('.dropdowns-container .dropdown-line .current').nth(0)
await dropdown.click()
await page.locator('li:text("RobotoMono")').click()
await viewFontMenu.dispatchEvent('change');
const padBody = await getPadBody(page)
const color = await padBody.evaluate((e) => {
return window.getComputedStyle(e).getPropertyValue("font-family")
})
// check if font changed to RobotoMono
const containsStr = color.toLowerCase().indexOf('robotomono');
expect(containsStr).not.toBe(-1);
});
});

View file

@ -25,7 +25,7 @@ test.describe('indentation button', function () {
const padBody = await getPadBody(page);
await page.locator('.buttonicon-indent').click()
const uls = padBody.locator('div').first().locator('ul li')
const uls = padBody.locator('div').first().locator('ul')
await expect(uls).toHaveCount(1);
});

View file

@ -0,0 +1,56 @@
'use strict';
import {expect, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
await goToNewPad(page);
})
test.describe('height regression after ace.js refactoring', function () {
test('clientHeight should equal scrollHeight with few lines', async function ({page}) {
const padBody = await getPadBody(page);
await padBody.click()
await clearPadContent(page)
const iframe = page.locator('iframe').first()
const scrollHeight = await iframe.evaluate((element) => {
return element.scrollHeight;
})
const clientHeight = await iframe.evaluate((element) => {
return element.clientHeight;
})
expect(clientHeight).toEqual(scrollHeight);
});
test('client height should be less than scrollHeight with many lines', async function ({page}) {
const padBody = await getPadBody(page);
await padBody.click()
await clearPadContent(page)
await writeToPad(page,'Test line\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n');
const iframe = page.locator('iframe').first()
const scrollHeight = await iframe.evaluate((element) => {
return element.scrollHeight;
})
const clientHeight = await iframe.evaluate((element) => {
return element.clientHeight;
})
// Need to poll because the heights take some time to settle.
expect(clientHeight).toBeLessThanOrEqual(scrollHeight);
});
});

View file

@ -0,0 +1,65 @@
import {expect, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
await goToNewPad(page);
})
test.describe('italic some text', function () {
test('makes text italic using button', async function ({page}) {
const padBody = await getPadBody(page);
await padBody.click()
await clearPadContent(page)
// get the first text element out of the inner iframe
const $firstTextElement = padBody.locator('div').first();
await $firstTextElement.click()
await writeToPad(page, 'Foo')
// select this text element
await padBody.click()
await page.keyboard.press('Control+A');
// get the bold button and click it
const $boldButton = page.locator('.buttonicon-italic');
await $boldButton.click();
// ace creates a new dom element when you press a button, just get the first text element again
const $newFirstTextElement = padBody.locator('div').first();
// is there a <i> element now?
// expect it to be italic
await expect($newFirstTextElement.locator('i')).toHaveCount(1);
// make sure the text hasn't changed
expect(await $newFirstTextElement.textContent()).toEqual(await $firstTextElement.textContent());
});
test('makes text italic using keypress', async function ({page}) {
const padBody = await getPadBody(page);
await padBody.click()
await clearPadContent(page)
// get the first text element out of the inner iframe
const $firstTextElement = padBody.locator('div').first();
// select this text element
await writeToPad(page, 'Foo')
await page.keyboard.press('Control+A');
await page.keyboard.press('Control+I');
// ace creates a new dom element when you press a button, just get the first text element again
const $newFirstTextElement = padBody.locator('div').first();
// is there a <i> element now?
// expect it to be italic
await expect($newFirstTextElement.locator('i')).toHaveCount(1);
// make sure the text hasn't changed
expect(await $newFirstTextElement.textContent()).toBe(await $firstTextElement.textContent());
});
});

View file

@ -0,0 +1,88 @@
import {expect, test} from "@playwright/test";
import {getPadBody, goToNewPad} from "../helper/padHelper";
import {showSettings} from "../helper/settingsHelper";
test.beforeEach(async ({ page, browser })=>{
const context = await browser.newContext()
await context.clearCookies()
await goToNewPad(page);
})
test.describe('Language select and change', function () {
// Destroy language cookies
test('makes text german', async function ({page}) {
// click on the settings button to make settings visible
await showSettings(page)
// click the language button
const languageDropDown = page.locator('.nice-select').nth(1)
await languageDropDown.click()
await page.locator('.nice-select').locator('[data-value=de]').click()
await expect(languageDropDown.locator('.current')).toHaveText('Deutsch')
// select german
await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)');
});
test('makes text English', async function ({page}) {
await showSettings(page)
// click the language button
await page.locator('.nice-select').nth(1).locator('.current').click()
await page.locator('.nice-select').locator('[data-value=de]').click()
// select german
await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)');
// change to english
await page.locator('.nice-select').nth(1).locator('.current').click()
await page.locator('.nice-select').locator('[data-value=en]').click()
// check if the language is now English
await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title !== 'Fett (Strg-B)');
});
test('changes direction when picking an rtl lang', async function ({page}) {
await showSettings(page)
// click the language button
await page.locator('.nice-select').nth(1).locator('.current').click()
await page.locator('.nice-select').locator('[data-value=de]').click()
// select german
await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title === 'Fett (Strg-B)');
// click the language button
await page.locator('.nice-select').nth(1).locator('.current').click()
// select arabic
// $languageoption.attr('selected','selected'); // Breaks the test..
await page.locator('.nice-select').locator('[data-value=ar]').click()
await page.waitForSelector('html[dir="rtl"]')
});
test('changes direction when picking an ltr lang', async function ({page}) {
await showSettings(page)
// change to english
const languageDropDown = page.locator('.nice-select').nth(1)
await languageDropDown.locator('.current').click()
await languageDropDown.locator('[data-value=en]').click()
await expect(languageDropDown.locator('.current')).toHaveText('English')
// check if the language is now English
await page.locator('.buttonicon-bold').evaluate((el) => el.parentElement!.title !== 'Fett (Strg-B)');
await page.waitForSelector('html[dir="ltr"]')
});
});

View file

@ -0,0 +1,109 @@
import {expect, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
await goToNewPad(page);
})
test.describe('ordered_list.js', function () {
test('issue #4748 keeps numbers increment on OL', async function ({page}) {
const padBody = await getPadBody(page);
await clearPadContent(page)
await writeToPad(page, 'Line 1')
await page.keyboard.press('Enter')
await writeToPad(page, 'Line 2')
const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')
await padBody.locator('div').first().selectText()
await $insertorderedlistButton.first().click();
const secondLine = padBody.locator('div').nth(1)
await secondLine.selectText()
await $insertorderedlistButton.click();
expect(await secondLine.locator('ol').getAttribute('start')).toEqual('2');
});
test('issue #1125 keeps the numbered list on enter for the new line', async function ({page}) {
// EMULATES PASTING INTO A PAD
const padBody = await getPadBody(page);
await clearPadContent(page)
await expect(padBody.locator('div')).toHaveCount(1)
const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')
await $insertorderedlistButton.click();
// type a bit, make a line break and type again
const firstTextElement = padBody.locator('div').first()
await firstTextElement.click()
await writeToPad(page, 'line 1')
await page.keyboard.press('Enter')
await writeToPad(page, 'line 2')
await page.keyboard.press('Enter')
await expect(padBody.locator('div span').nth(1)).toHaveText('line 2');
const $newSecondLine = padBody.locator('div').nth(1)
expect(await $newSecondLine.locator('ol li').count()).toEqual(1);
await expect($newSecondLine.locator('ol li').nth(0)).toHaveText('line 2');
const hasLineNumber = await $newSecondLine.locator('ol').getAttribute('start');
// This doesn't work because pasting in content doesn't work
expect(Number(hasLineNumber)).toBe(2);
});
});
test.describe('Pressing Tab in an OL increases and decreases indentation', function () {
test('indent and de-indent list item with keypress', async function ({page}) {
const padBody = await getPadBody(page);
// get the first text element out of the inner iframe
const $firstTextElement = padBody.locator('div').first();
// select this text element
await $firstTextElement.selectText()
const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')
await $insertorderedlistButton.click()
await page.keyboard.press('Tab')
await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1)
await page.keyboard.press('Shift+Tab')
await expect(padBody.locator('div').first().locator('.list-number1')).toHaveCount(1)
});
});
test.describe('Pressing indent/outdent button in an OL increases and ' +
'decreases indentation and bullet / ol formatting', function () {
test('indent and de-indent list item with indent button', async function ({page}) {
const padBody = await getPadBody(page);
// get the first text element out of the inner iframe
const $firstTextElement = padBody.locator('div').first();
// select this text element
await $firstTextElement.selectText()
const $insertorderedlistButton = page.locator('.buttonicon-insertorderedlist')
await $insertorderedlistButton.click()
const $indentButton = page.locator('.buttonicon-indent')
await $indentButton.dblclick() // make it indented twice
const outdentButton = page.locator('.buttonicon-outdent')
await expect(padBody.locator('div').first().locator('.list-number3')).toHaveCount(1)
await outdentButton.click(); // make it deindented to 1
await expect(padBody.locator('div').first().locator('.list-number2')).toHaveCount(1)
});
});

View file

@ -0,0 +1,65 @@
import {expect, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
await goToNewPad(page);
})
test.describe('undo button then redo button', function () {
test('redo some typing with button', async function ({page}) {
const padBody = await getPadBody(page);
// get the first text element inside the editable space
const $firstTextElement = padBody.locator('div span').first();
const originalValue = await $firstTextElement.textContent(); // get the original value
const newString = 'Foo';
await $firstTextElement.focus()
expect(await $firstTextElement.textContent()).toContain(originalValue);
await padBody.click()
await clearPadContent(page)
await writeToPad(page, newString); // send line 1 to the pad
const modifiedValue = await $firstTextElement.textContent(); // get the modified value
expect(modifiedValue).not.toBe(originalValue); // expect the value to change
// get undo and redo buttons // click the buttons
await page.locator('.buttonicon-undo').click() // removes foo
await page.locator('.buttonicon-redo').click() // resends foo
await expect($firstTextElement).toHaveText(newString);
const finalValue = await padBody.locator('div').first().textContent();
expect(finalValue).toBe(modifiedValue); // expect the value to change
});
test('redo some typing with keypress', async function ({page}) {
const padBody = await getPadBody(page);
// get the first text element inside the editable space
const $firstTextElement = padBody.locator('div span').first();
const originalValue = await $firstTextElement.textContent(); // get the original value
const newString = 'Foo';
await padBody.click()
await clearPadContent(page)
await writeToPad(page, newString); // send line 1 to the pad
const modifiedValue = await $firstTextElement.textContent(); // get the modified value
expect(modifiedValue).not.toBe(originalValue); // expect the value to change
// undo the change
await padBody.click()
await page.keyboard.press('Control+Z');
await page.keyboard.press('Control+Y'); // redo the change
await expect($firstTextElement).toHaveText(newString);
const finalValue = await padBody.locator('div').first().textContent();
expect(finalValue).toBe(modifiedValue); // expect the value to change
});
});

View file

@ -0,0 +1,30 @@
import {expect, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
await goToNewPad(page);
})
test.describe('strikethrough button', function () {
test('makes text strikethrough', async function ({page}) {
const padBody = await getPadBody(page);
// get the first text element out of the inner iframe
const $firstTextElement = padBody.locator('div').first();
// select this text element
await $firstTextElement.selectText()
// get the strikethrough button and click it
await page.locator('.buttonicon-strikethrough').click();
// ace creates a new dom element when you press a button, just get the first text element again
// is there a <i> element now?
await expect($firstTextElement.locator('s')).toHaveCount(1);
// make sure the text hasn't changed
expect(await $firstTextElement.textContent()).toEqual(await $firstTextElement.textContent());
});
});

View file

@ -0,0 +1,37 @@
import {expect, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
// create a new pad before each test run
await goToNewPad(page);
})
// deactivated, we need a nice way to get the timeslider, this is ugly
test.describe('timeslider button takes you to the timeslider of a pad', function () {
test('timeslider contained in URL', async function ({page}) {
const padBody = await getPadBody(page);
await clearPadContent(page)
await writeToPad(page, 'Foo'); // send line 1 to the pad
// get the first text element inside the editable space
const $firstTextElement = padBody.locator('div span').first();
const originalValue = await $firstTextElement.textContent(); // get the original value
await $firstTextElement.click()
await writeToPad(page, 'Testing'); // send line 1 to the pad
const modifiedValue = await $firstTextElement.textContent(); // get the modified value
expect(modifiedValue).not.toBe(originalValue); // expect the value to change
const $timesliderButton = page.locator('.buttonicon-history');
await $timesliderButton.click(); // So click the timeslider link
await page.waitForSelector('#timeslider-wrapper')
const iFrameURL = page.url(); // get the url
const inTimeslider = iFrameURL.indexOf('timeslider') !== -1;
expect(inTimeslider).toBe(true); // expect the value to change
});
});

View file

@ -0,0 +1,76 @@
'use strict';
import {expect, Page, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
import {gotoTimeslider} from "../helper/timeslider";
test.beforeEach(async ({ page })=>{
await goToNewPad(page);
})
test.describe('timeslider follow', function () {
// TODO needs test if content is also followed, when user a makes edits
// while user b is in the timeslider
test("content as it's added to timeslider", async function ({page}) {
// send 6 revisions
const revs = 6;
const message = 'a\n\n\n\n\n\n\n\n\n\n';
const newLines = message.split('\n').length;
for (let i = 0; i < revs; i++) {
await writeToPad(page, message)
}
await gotoTimeslider(page,0);
expect(page.url()).toContain('#0');
const originalTop = await page.evaluate(() => {
return window.document.querySelector('#innerdocbody')!.getBoundingClientRect().top;
});
// set to follow contents as it arrives
await page.check('#options-followContents');
await page.click('#playpause_button_icon');
// wait for the scroll
await page.waitForTimeout(1000)
const currentOffset = await page.evaluate(() => {
return window.document.querySelector('#innerdocbody')!.getBoundingClientRect().top;
});
expect(currentOffset).toBeLessThanOrEqual(originalTop);
});
/**
* Tests for bug described in #4389
* The goal is to scroll to the first line that contains a change right before
* the change is applied.
*/
test('only to lines that exist in the pad view, regression test for #4389', async function ({page}) {
const padBody = await getPadBody(page)
await padBody.click()
await clearPadContent(page)
await writeToPad(page,'Test line\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n');
await padBody.locator('div').nth(40).click();
await writeToPad(page, 'Another test line');
await gotoTimeslider(page, 200);
// set to follow contents as it arrives
await page.check('#options-followContents');
await page.waitForTimeout(1000)
const oldYPosition = await page.locator('#editorcontainerbox').evaluate((el) => {
return el.scrollTop;
})
expect(oldYPosition).toBe(0);
});
});

View file

@ -0,0 +1,56 @@
'use strict';
import {expect, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
await goToNewPad(page);
})
test.describe('undo button', function () {
test('undo some typing by clicking undo button', async function ({page}) {
const padBody = await getPadBody(page);
await padBody.click()
await clearPadContent(page)
// get the first text element inside the editable space
const firstTextElement = padBody.locator('div').first()
const originalValue = await firstTextElement.textContent(); // get the original value
await firstTextElement.focus()
await writeToPad(page, 'foo'); // send line 1 to the pad
const modifiedValue = await firstTextElement.textContent(); // get the modified value
expect(modifiedValue).not.toBe(originalValue); // expect the value to change
// get clear authorship button as a variable
const undoButton = page.locator('.buttonicon-undo')
await undoButton.click() // click the button
await expect(firstTextElement).toHaveText(originalValue!);
});
test('undo some typing using a keypress', async function ({page}) {
const padBody = await getPadBody(page);
await padBody.click()
await clearPadContent(page)
// get the first text element inside the editable space
const firstTextElement = padBody.locator('div').first()
const originalValue = await firstTextElement.textContent(); // get the original value
await firstTextElement.focus()
await writeToPad(page, 'foo'); // send line 1 to the pad
const modifiedValue = await firstTextElement.textContent(); // get the modified value
expect(modifiedValue).not.toBe(originalValue); // expect the value to change
// undo the change
await page.keyboard.press('Control+Z');
await page.waitForTimeout(1000)
await expect(firstTextElement).toHaveText(originalValue!);
});
});

View file

@ -0,0 +1,127 @@
import {expect, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
// create a new pad before each test run
await goToNewPad(page);
})
test.describe('unordered_list.js', function () {
test.describe('assign unordered list', function () {
test('insert unordered list text then removes by outdent', async function ({page}) {
const padBody = await getPadBody(page);
const originalText = await padBody.locator('div').first().textContent();
const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');
await $insertunorderedlistButton.click();
await expect(padBody.locator('div').first()).toHaveText(originalText!);
await expect(padBody.locator('div ul li')).toHaveCount(1);
// remove indentation by bullet and ensure text string remains the same
const $outdentButton = page.locator('.buttonicon-outdent');
await $outdentButton.click();
await expect(padBody.locator('div').first()).toHaveText(originalText!);
});
});
test.describe('unassign unordered list', function () {
// create a new pad before each test run
test('insert unordered list text then remove by clicking list again', async function ({page}) {
const padBody = await getPadBody(page);
const originalText = await padBody.locator('div').first().textContent();
await padBody.locator('div').first().selectText()
const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');
await $insertunorderedlistButton.click();
await expect(padBody.locator('div').first()).toHaveText(originalText!);
await expect(padBody.locator('div ul li')).toHaveCount(1);
// remove indentation by bullet and ensure text string remains the same
await $insertunorderedlistButton.click();
await expect(padBody.locator('div').locator('ul')).toHaveCount(0)
});
});
test.describe('keep unordered list on enter key', function () {
test('Keeps the unordered list on enter for the new line', async function ({page}) {
const padBody = await getPadBody(page);
await clearPadContent(page)
await expect(padBody.locator('div')).toHaveCount(1)
const $insertorderedlistButton = page.locator('.buttonicon-insertunorderedlist')
await $insertorderedlistButton.click();
// type a bit, make a line break and type again
const $firstTextElement = padBody.locator('div').first();
await $firstTextElement.click()
await page.keyboard.type('line 1');
await page.keyboard.press('Enter');
await page.keyboard.type('line 2');
await page.keyboard.press('Enter');
await expect(padBody.locator('div span')).toHaveCount(2);
const $newSecondLine = padBody.locator('div').nth(1)
await expect($newSecondLine.locator('ul')).toHaveCount(1);
await expect($newSecondLine).toHaveText('line 2');
});
});
test.describe('Pressing Tab in an UL increases and decreases indentation', 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();
// select this text element
await $firstTextElement.selectText();
const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');
await $insertunorderedlistButton.click();
await padBody.locator('div').first().click();
await page.keyboard.press('Home');
await page.keyboard.press('Tab');
await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1);
await page.keyboard.press('Shift+Tab');
await expect(padBody.locator('div').first().locator('.list-bullet1')).toHaveCount(1);
});
});
test.describe('Pressing indent/outdent button in an UL increases and decreases indentation ' +
'and bullet / ol formatting', function () {
test('indent and de-indent list item with indent button', async function ({page}) {
const padBody = await getPadBody(page);
// get the first text element out of the inner iframe
const $firstTextElement = padBody.locator('div').first();
// select this text element
await $firstTextElement.selectText();
const $insertunorderedlistButton = page.locator('.buttonicon-insertunorderedlist');
await $insertunorderedlistButton.click();
await page.locator('.buttonicon-indent').click();
await expect(padBody.locator('div').first().locator('.list-bullet2')).toHaveCount(1);
const outdentButton = page.locator('.buttonicon-outdent');
await outdentButton.click();
await expect(padBody.locator('div').first().locator('.list-bullet1')).toHaveCount(1);
});
});
});

View file

@ -0,0 +1,51 @@
import {expect, test} from "@playwright/test";
import {clearPadContent, getPadBody, goToNewPad, writeToPad} from "../helper/padHelper";
test.beforeEach(async ({ page })=>{
await goToNewPad(page);
})
test.describe('entering a URL makes a link', function () {
for (const url of ['https://etherpad.org', 'www.etherpad.org', 'https://www.etherpad.org']) {
test(url, async function ({page}) {
const padBody = await getPadBody(page);
await clearPadContent(page)
const url = 'https://etherpad.org';
await writeToPad(page, url);
await expect(padBody.locator('div').first()).toHaveText(url);
await expect(padBody.locator('a')).toHaveText(url);
await expect(padBody.locator('a')).toHaveAttribute('href', url);
});
}
});
test.describe('special characters inside URL', async function () {
for (const char of '-:@_.,~%+/?=&#!;()[]$\'*') {
const url = `https://etherpad.org/${char}foo`;
test(url, async function ({page}) {
const padBody = await getPadBody(page);
await clearPadContent(page)
await padBody.click()
await clearPadContent(page)
await writeToPad(page, url);
await expect(padBody.locator('div').first()).toHaveText(url);
await expect(padBody.locator('a')).toHaveText(url);
await expect(padBody.locator('a')).toHaveAttribute('href', url);
});
}
});
test.describe('punctuation after URL is ignored', ()=> {
for (const char of ':.,;?!)]\'*') {
const want = 'https://etherpad.org';
const input = want + char;
test(input, async function ({page}) {
const padBody = await getPadBody(page);
await clearPadContent(page)
await writeToPad(page, input);
await expect(padBody.locator('a')).toHaveCount(1);
await expect(padBody.locator('a')).toHaveAttribute('href', want);
});
}
});

View file

@ -1,102 +0,0 @@
'use strict';
describe('change user color', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('Color picker matches original color and remembers the user color' +
' after a refresh', async function () {
this.timeout(10000);
let chrome$ = helper.padChrome$;
// click on the settings button to make settings visible
let $userButton = chrome$('.buttonicon-showusers');
$userButton.trigger('click');
let $userSwatch = chrome$('#myswatch');
$userSwatch.trigger('click');
const fb = chrome$.farbtastic('#colorpicker');
const $colorPickerSave = chrome$('#mycolorpickersave');
let $colorPickerPreview = chrome$('#mycolorpickerpreview');
// Same color represented in two different ways
const testColorHash = '#abcdef';
const testColorRGB = 'rgb(171, 205, 239)';
// Check that the color picker matches the automatically assigned random color on the swatch.
// NOTE: This has a tiny chance of creating a false positive for passing in the
// off-chance the randomly assigned color is the same as the test color.
expect($colorPickerPreview.css('background-color')).to.be($userSwatch.css('background-color'));
// The swatch updates as the test color is picked.
fb.setColor(testColorHash);
expect($colorPickerPreview.css('background-color')).to.be(testColorRGB);
$colorPickerSave.trigger('click');
expect($userSwatch.css('background-color')).to.be(testColorRGB);
// give it a second to save the color on the server side
await new Promise((resolve) => setTimeout(resolve, 1000));
// get a new pad, but don't clear the cookies
await helper.aNewPad({clearCookies: false});
chrome$ = helper.padChrome$;
// click on the settings button to make settings visible
$userButton = chrome$('.buttonicon-showusers');
$userButton.trigger('click');
$userSwatch = chrome$('#myswatch');
$userSwatch.trigger('click');
$colorPickerPreview = chrome$('#mycolorpickerpreview');
expect($colorPickerPreview.css('background-color')).to.be(testColorRGB);
expect($userSwatch.css('background-color')).to.be(testColorRGB);
});
it('Own user color is shown when you enter a chat', function (done) {
this.timeout(1000);
const chrome$ = helper.padChrome$;
const $colorOption = helper.padChrome$('#options-colorscheck');
if (!$colorOption.is(':checked')) {
$colorOption.trigger('click');
}
// click on the settings button to make settings visible
const $userButton = chrome$('.buttonicon-showusers');
$userButton.trigger('click');
const $userSwatch = chrome$('#myswatch');
$userSwatch.trigger('click');
const fb = chrome$.farbtastic('#colorpicker');
const $colorPickerSave = chrome$('#mycolorpickersave');
// Same color represented in two different ways
const testColorHash = '#abcdef';
const testColorRGB = 'rgb(171, 205, 239)';
fb.setColor(testColorHash);
$colorPickerSave.trigger('click');
// click on the chat button to make chat visible
const $chatButton = chrome$('#chaticon');
$chatButton.trigger('click');
const $chatInput = chrome$('#chatinput');
$chatInput.sendkeys('O hi'); // simulate a keypress of typing user
$chatInput.sendkeys('{enter}');
// wait until the chat message shows up
helper.waitFor(() => chrome$('#chattext').children('p').length !== 0).done(() => {
const $firstChatMessage = chrome$('#chattext').children('p');
// expect the first chat message to be of the user's color
expect($firstChatMessage.css('background-color')).to.be(testColorRGB);
done();
});
});
});

View file

@ -1,31 +0,0 @@
'use strict';
describe('font select', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('makes text RobotoMono', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// click on the settings button to make settings visible
const $settingsButton = chrome$('.buttonicon-settings');
$settingsButton.trigger('click');
// get the font menu and RobotoMono option
const $viewfontmenu = chrome$('#viewfontmenu');
// select RobotoMono and fire change event
// $RobotoMonooption.attr('selected','selected');
// commenting out above will break safari test
$viewfontmenu.val('RobotoMono');
$viewfontmenu.trigger('change');
// check if font changed to RobotoMono
const fontFamily = inner$('body').css('font-family').toLowerCase();
const containsStr = fontFamily.indexOf('robotomono');
expect(containsStr).to.not.be(-1);
});
});

View file

@ -167,7 +167,7 @@ describe('the test helper', function () {
expect(Date.now() - before).to.be.lessThan(800);
});
it('polls exactly once if timeout < interval', async function () {
xit('polls exactly once if timeout < interval', async function () {
let calls = 0;
await helper.waitFor(() => { calls++; }, 1, 1000)
.fail(() => {}) // Suppress the redundant uncatchable exception.
@ -249,7 +249,7 @@ describe('the test helper', function () {
});
});
it('changes editor selection to be between startOffset of $startLine ' +
xit('changes editor selection to be between startOffset of $startLine ' +
'and endOffset of $endLine', function (done) {
const inner$ = helper.padInner$;

View file

@ -1,31 +0,0 @@
'use strict';
describe('height regression after ace.js refactoring', function () {
before(async function () {
await helper.aNewPad();
});
// everything fits inside the viewport
it('clientHeight should equal scrollHeight with few lines', async function () {
await helper.clearPad();
const outerHtml = helper.padChrome$('iframe')[0].contentDocument.documentElement;
// Give some time for the heights to settle.
await new Promise((resolve) => setTimeout(resolve, 100));
expect(outerHtml.clientHeight).to.be(outerHtml.scrollHeight);
});
it('client height should be less than scrollHeight with many lines', async function () {
await helper.clearPad();
await helper.edit('Test line\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n');
const outerHtml = helper.padChrome$('iframe')[0].contentDocument.documentElement;
// Need to poll because the heights take some time to settle.
await helper.waitForPromise(() => outerHtml.clientHeight < outerHtml.scrollHeight);
});
});

View file

@ -1,62 +0,0 @@
'use strict';
describe('italic some text', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('makes text italic using button', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// get the first text element out of the inner iframe
const $firstTextElement = inner$('div').first();
// select this text element
$firstTextElement.sendkeys('{selectall}');
// get the bold button and click it
const $boldButton = chrome$('.buttonicon-italic');
$boldButton.trigger('click');
// ace creates a new dom element when you press a button, just get the first text element again
const $newFirstTextElement = inner$('div').first();
// is there a <i> element now?
const isItalic = $newFirstTextElement.find('i').length === 1;
// expect it to be bold
expect(isItalic).to.be(true);
// make sure the text hasn't changed
expect($newFirstTextElement.text()).to.eql($firstTextElement.text());
});
it('makes text italic using keypress', async function () {
const inner$ = helper.padInner$;
// get the first text element out of the inner iframe
const $firstTextElement = inner$('div').first();
// select this text element
$firstTextElement.sendkeys('{selectall}');
const e = new inner$.Event(helper.evtType);
e.ctrlKey = true; // Control key
e.which = 105; // i
inner$('#innerdocbody').trigger(e);
// ace creates a new dom element when you press a button, just get the first text element again
const $newFirstTextElement = inner$('div').first();
// is there a <i> element now?
const isItalic = $newFirstTextElement.find('i').length === 1;
// expect it to be bold
expect(isItalic).to.be(true);
// make sure the text hasn't changed
expect($newFirstTextElement.text()).to.eql($firstTextElement.text());
});
});

View file

@ -1,121 +0,0 @@
'use strict';
describe('Language select and change', function () {
// Destroy language cookies
window.Cookies.remove('language');
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
// Destroy language cookies
it('makes text german', async function () {
this.timeout(1000);
const chrome$ = helper.padChrome$;
// click on the settings button to make settings visible
const $settingsButton = chrome$('.buttonicon-settings');
$settingsButton.trigger('click');
// click the language button
const $language = chrome$('#languagemenu');
const $languageoption = $language.find('[value=de]');
// select german
$languageoption.attr('selected', 'selected');
$language.trigger('change');
await helper.waitForPromise(
() => chrome$('.buttonicon-bold').parent()[0].title === 'Fett (Strg-B)');
// get the value of the bold button
const $boldButton = chrome$('.buttonicon-bold').parent();
// get the title of the bold button
const boldButtonTitle = $boldButton[0].title;
// check if the language is now german
expect(boldButtonTitle).to.be('Fett (Strg-B)');
});
it('makes text English', async function () {
this.timeout(1000);
const chrome$ = helper.padChrome$;
// click on the settings button to make settings visible
const $settingsButton = chrome$('.buttonicon-settings');
$settingsButton.trigger('click');
// click the language button
const $language = chrome$('#languagemenu');
// select english
$language.val('en');
$language.trigger('change');
// get the value of the bold button
let $boldButton = chrome$('.buttonicon-bold').parent();
await helper.waitForPromise(() => $boldButton[0].title !== 'Fett (Strg+B)');
// get the value of the bold button
$boldButton = chrome$('.buttonicon-bold').parent();
// get the title of the bold button
const boldButtonTitle = $boldButton[0].title;
// check if the language is now English
expect(boldButtonTitle).to.be('Bold (Ctrl+B)');
});
it('changes direction when picking an rtl lang', async function () {
// TODO: flaky
if (window.bowser.safari) {
this.timeout(5000);
} else {
this.timeout(1000);
}
const chrome$ = helper.padChrome$;
// click on the settings button to make settings visible
const $settingsButton = chrome$('.buttonicon-settings');
$settingsButton.trigger('click');
// click the language button
const $language = chrome$('#languagemenu');
const $languageoption = $language.find('[value=ar]');
// select arabic
// $languageoption.attr('selected','selected'); // Breaks the test..
$language.val('ar');
$languageoption.trigger('change');
await helper.waitForPromise(() => chrome$('html')[0].dir !== 'ltr');
// check if the document's direction was changed
expect(chrome$('html')[0].dir).to.be('rtl');
});
it('changes direction when picking an ltr lang', async function () {
const chrome$ = helper.padChrome$;
// click on the settings button to make settings visible
const $settingsButton = chrome$('.buttonicon-settings');
$settingsButton.trigger('click');
// click the language button
const $language = chrome$('#languagemenu');
const $languageoption = $language.find('[value=en]');
// select english
// select arabic
$languageoption.attr('selected', 'selected');
$language.val('en');
$languageoption.trigger('change');
await helper.waitForPromise(() => chrome$('html')[0].dir !== 'rtl');
// check if the document's direction was changed
expect(chrome$('html')[0].dir).to.be('ltr');
});
});

View file

@ -1,233 +0,0 @@
'use strict';
describe('ordered_list.js', function () {
describe('assign ordered list', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('inserts ordered list text', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
$insertorderedlistButton.trigger('click');
await helper.waitForPromise(() => inner$('div').first().find('ol li').length === 1);
});
context('when user presses Ctrl+Shift+N', function () {
context('and pad shortcut is enabled', function () {
beforeEach(async function () {
const originalHTML = helper.padInner$('body').html();
makeSureShortcutIsEnabled('cmdShiftN');
triggerCtrlShiftShortcut('N');
await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML);
});
it('inserts unordered list', async function () {
await helper.waitForPromise(
() => helper.padInner$('div').first().find('ol li').length === 1);
});
});
context('and pad shortcut is disabled', function () {
beforeEach(async function () {
const originalHTML = helper.padInner$('body').html();
makeSureShortcutIsDisabled('cmdShiftN');
triggerCtrlShiftShortcut('N');
try {
// The HTML should not change. Briefly wait for it to change and fail if it does change.
await helper.waitForPromise(
() => helper.padInner$('body').html() !== originalHTML, 500);
} catch (err) {
// We want the test to pass if the above wait timed out. (If it timed out that
// means the HTML never changed, which is a good thing.)
// TODO: Re-throw non-"condition never became true" errors to avoid false positives.
}
// This will fail if the above `waitForPromise()` succeeded.
expect(helper.padInner$('body').html()).to.be(originalHTML);
});
it('does not insert unordered list', async function () {
this.timeout(3000);
try {
await helper.waitForPromise(
() => helper.padInner$('div').first().find('ol li').length === 1);
} catch (err) {
return;
}
expect().fail('Unordered list inserted, should ignore shortcut');
});
});
});
context('when user presses Ctrl+Shift+1', function () {
context('and pad shortcut is enabled', function () {
beforeEach(async function () {
const originalHTML = helper.padInner$('body').html();
makeSureShortcutIsEnabled('cmdShift1');
triggerCtrlShiftShortcut('1');
await helper.waitForPromise(() => helper.padInner$('body').html() !== originalHTML);
});
it('inserts unordered list', async function () {
helper.waitForPromise(() => helper.padInner$('div').first().find('ol li').length === 1);
});
});
context('and pad shortcut is disabled', function () {
beforeEach(async function () {
const originalHTML = helper.padInner$('body').html();
makeSureShortcutIsDisabled('cmdShift1');
triggerCtrlShiftShortcut('1');
try {
// The HTML should not change. Briefly wait for it to change and fail if it does change.
await helper.waitForPromise(
() => helper.padInner$('body').html() !== originalHTML, 500);
} catch (err) {
// We want the test to pass if the above wait timed out. (If it timed out that
// means the HTML never changed, which is a good thing.)
// TODO: Re-throw non-"condition never became true" errors to avoid false positives.
}
// This will fail if the above `waitForPromise()` succeeded.
expect(helper.padInner$('body').html()).to.be(originalHTML);
});
it('does not insert unordered list', async function () {
this.timeout(3000);
try {
await helper.waitForPromise(
() => helper.padInner$('div').first().find('ol li').length === 1);
} catch (err) {
return;
}
expect().fail('Unordered list inserted, should ignore shortcut');
});
});
});
it('issue #4748 keeps numbers increment on OL', async function () {
this.timeout(5000);
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
const $firstLine = inner$('div').first();
$firstLine.sendkeys('{selectall}');
$insertorderedlistButton.trigger('click');
const $secondLine = inner$('div').first().next();
$secondLine.sendkeys('{selectall}');
$insertorderedlistButton.trigger('click');
expect($secondLine.find('ol').attr('start') === 2);
});
xit('issue #1125 keeps the numbered list on enter for the new line', async function () {
// EMULATES PASTING INTO A PAD
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
$insertorderedlistButton.trigger('click');
// type a bit, make a line break and type again
const $firstTextElement = inner$('div span').first();
$firstTextElement.sendkeys('line 1');
$firstTextElement.sendkeys('{enter}');
$firstTextElement.sendkeys('line 2');
$firstTextElement.sendkeys('{enter}');
await helper.waitForPromise(() => inner$('div span').first().text().indexOf('line 2') === -1);
const $newSecondLine = inner$('div').first().next();
const hasOLElement = $newSecondLine.find('ol li').length === 1;
expect(hasOLElement).to.be(true);
expect($newSecondLine.text()).to.be('line 2');
const hasLineNumber = $newSecondLine.find('ol').attr('start') === 2;
// This doesn't work because pasting in content doesn't work
expect(hasLineNumber).to.be(true);
});
const triggerCtrlShiftShortcut = (shortcutChar) => {
const inner$ = helper.padInner$;
const e = new inner$.Event(helper.evtType);
e.ctrlKey = true;
e.shiftKey = true;
e.which = shortcutChar.toString().charCodeAt(0);
inner$('#innerdocbody').trigger(e);
};
const makeSureShortcutIsDisabled = (shortcut) => {
helper.padChrome$.window.clientVars.padShortcutEnabled[shortcut] = false;
};
const makeSureShortcutIsEnabled = (shortcut) => {
helper.padChrome$.window.clientVars.padShortcutEnabled[shortcut] = true;
};
});
describe('Pressing Tab in an OL increases and decreases indentation', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('indent and de-indent list item with keypress', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// get the first text element out of the inner iframe
const $firstTextElement = inner$('div').first();
// select this text element
$firstTextElement.sendkeys('{selectall}');
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
$insertorderedlistButton.trigger('click');
const e = new inner$.Event(helper.evtType);
e.keyCode = 9; // tab
inner$('#innerdocbody').trigger(e);
expect(inner$('div').first().find('.list-number2').length === 1).to.be(true);
e.shiftKey = true; // shift
e.keyCode = 9; // tab
inner$('#innerdocbody').trigger(e);
await helper.waitForPromise(() => inner$('div').first().find('.list-number1').length === 1);
});
});
describe('Pressing indent/outdent button in an OL increases and ' +
'decreases indentation and bullet / ol formatting', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('indent and de-indent list item with indent button', async function () {
this.timeout(1000);
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// get the first text element out of the inner iframe
const $firstTextElement = inner$('div').first();
// select this text element
$firstTextElement.sendkeys('{selectall}');
const $insertorderedlistButton = chrome$('.buttonicon-insertorderedlist');
$insertorderedlistButton.trigger('click');
const $indentButton = chrome$('.buttonicon-indent');
$indentButton.trigger('click'); // make it indented twice
expect(inner$('div').first().find('.list-number2').length === 1).to.be(true);
const $outdentButton = chrome$('.buttonicon-outdent');
$outdentButton.trigger('click'); // make it deindented to 1
await helper.waitForPromise(() => inner$('div').first().find('.list-number1').length === 1);
});
});
});

View file

@ -1,59 +0,0 @@
'use strict';
describe('undo button then redo button', function () {
beforeEach(async function () {
await helper.aNewPad();
});
it('redo some typing with button', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// get the first text element inside the editable space
const $firstTextElement = inner$('div span').first();
const originalValue = $firstTextElement.text(); // get the original value
const newString = 'Foo';
$firstTextElement.sendkeys(newString); // send line 1 to the pad
const modifiedValue = $firstTextElement.text(); // get the modified value
expect(modifiedValue).not.to.be(originalValue); // expect the value to change
// get undo and redo buttons
const $undoButton = chrome$('.buttonicon-undo');
const $redoButton = chrome$('.buttonicon-redo');
// click the buttons
$undoButton.trigger('click'); // removes foo
$redoButton.trigger('click'); // resends foo
await helper.waitForPromise(() => inner$('div span').first().text() === newString);
const finalValue = inner$('div').first().text();
expect(finalValue).to.be(modifiedValue); // expect the value to change
});
it('redo some typing with keypress', async function () {
const inner$ = helper.padInner$;
// get the first text element inside the editable space
const $firstTextElement = inner$('div span').first();
const originalValue = $firstTextElement.text(); // get the original value
const newString = 'Foo';
$firstTextElement.sendkeys(newString); // send line 1 to the pad
const modifiedValue = $firstTextElement.text(); // get the modified value
expect(modifiedValue).not.to.be(originalValue); // expect the value to change
let e = new inner$.Event(helper.evtType);
e.ctrlKey = true; // Control key
e.which = 90; // z
inner$('#innerdocbody').trigger(e);
e = new inner$.Event(helper.evtType);
e.ctrlKey = true; // Control key
e.which = 121; // y
inner$('#innerdocbody').trigger(e);
await helper.waitForPromise(() => inner$('div span').first().text() === newString);
const finalValue = inner$('div').first().text();
expect(finalValue).to.be(modifiedValue); // expect the value to change
});
});

View file

@ -1,35 +0,0 @@
'use strict';
describe('strikethrough button', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('makes text strikethrough', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// get the first text element out of the inner iframe
const $firstTextElement = inner$('div').first();
// select this text element
$firstTextElement.sendkeys('{selectall}');
// get the strikethrough button and click it
const $strikethroughButton = chrome$('.buttonicon-strikethrough');
$strikethroughButton.trigger('click');
// ace creates a new dom element when you press a button, just get the first text element again
const $newFirstTextElement = inner$('div').first();
// is there a <i> element now?
const isstrikethrough = $newFirstTextElement.find('s').length === 1;
// expect it to be strikethrough
expect(isstrikethrough).to.be(true);
// make sure the text hasn't changed
expect($newFirstTextElement.text()).to.eql($firstTextElement.text());
});
});

View file

@ -1,41 +0,0 @@
'use strict';
// deactivated, we need a nice way to get the timeslider, this is ugly
xdescribe('timeslider button takes you to the timeslider of a pad', function () {
beforeEach(async function () {
await helper.aNewPad();
});
it('timeslider contained in URL', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// get the first text element inside the editable space
const $firstTextElement = inner$('div span').first();
const originalValue = $firstTextElement.text(); // get the original value
$firstTextElement.sendkeys('Testing'); // send line 1 to the pad
const modifiedValue = $firstTextElement.text(); // get the modified value
expect(modifiedValue).not.to.be(originalValue); // expect the value to change
// The value has changed so we can..
await helper.waitForPromise(() => modifiedValue !== originalValue);
const $timesliderButton = chrome$('#timesliderlink');
$timesliderButton.trigger('click'); // So click the timeslider link
await helper.waitForPromise(() => {
const iFrameURL = chrome$.window.location.href;
if (iFrameURL) {
return iFrameURL.indexOf('timeslider') !== -1;
} else {
return false; // the URL hasnt been set yet
}
});
// click the buttons
const iFrameURL = chrome$.window.location.href; // get the url
const inTimeslider = iFrameURL.indexOf('timeslider') !== -1;
expect(inTimeslider).to.be(true); // expect the value to change
});
});

View file

@ -1,101 +0,0 @@
'use strict';
describe('timeslider follow', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
// TODO needs test if content is also followed, when user a makes edits
// while user b is in the timeslider
it("content as it's added to timeslider", async function () {
this.timeout(20000);
// send 6 revisions
const revs = 6;
const message = 'a\n\n\n\n\n\n\n\n\n\n';
const newLines = message.split('\n').length;
for (let i = 0; i < revs; i++) {
await helper.edit(message, newLines * i + 1);
}
await helper.gotoTimeslider(0);
await helper.waitForPromise(() => helper.contentWindow().location.hash === '#0');
const originalTop = helper.contentWindow().$('#innerdocbody').offset();
// set to follow contents as it arrives
helper.contentWindow().$('#options-followContents').prop('checked', true);
helper.contentWindow().$('#playpause_button_icon').trigger('click');
let newTop;
await helper.waitForPromise(() => {
newTop = helper.contentWindow().$('#innerdocbody').offset();
return newTop.top < originalTop.top;
});
});
/**
* Tests for bug described in #4389
* The goal is to scroll to the first line that contains a change right before
* the change is applied.
*/
it('only to lines that exist in the pad view, regression test for #4389', async function () {
await helper.clearPad();
await helper.edit('Test line\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n' +
'\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n');
await helper.edit('Another test line', 40);
await helper.gotoTimeslider();
// set to follow contents as it arrives
helper.contentWindow().$('#options-followContents').prop('checked', true);
const oldYPosition = helper.contentWindow().$('#editorcontainerbox')[0].scrollTop;
expect(oldYPosition).to.be(0);
/**
* pad content rev 0 [default Pad text]
* pad content rev 1 ['']
* pad content rev 2 ['Test line','','', ..., '']
* pad content rev 3 ['Test line','',..., 'Another test line', ..., '']
*/
// line 40 changed
helper.contentWindow().$('#leftstep').trigger('click');
await helper.waitForPromise(() => hasFollowedToLine(40));
// line 1 is the first line that changed
helper.contentWindow().$('#leftstep').trigger('click');
await helper.waitForPromise(() => hasFollowedToLine(1));
// line 1 changed
helper.contentWindow().$('#leftstep').trigger('click');
await helper.waitForPromise(() => hasFollowedToLine(1));
// line 1 changed
helper.contentWindow().$('#rightstep').trigger('click');
await helper.waitForPromise(() => hasFollowedToLine(1));
// line 1 is the first line that changed
helper.contentWindow().$('#rightstep').trigger('click');
await helper.waitForPromise(() => hasFollowedToLine(1));
// line 40 changed
helper.contentWindow().$('#rightstep').trigger('click');
helper.waitForPromise(() => hasFollowedToLine(40));
});
});
/**
* @param {number} lineNum
* @returns {boolean} scrolled to the lineOffset?
*/
const hasFollowedToLine = (lineNum) => {
const scrollPosition = helper.contentWindow().$('#editorcontainerbox')[0].scrollTop;
const lineOffset =
helper.contentWindow().$('#innerdocbody').find(`div:nth-child(${lineNum})`)[0].offsetTop;
return Math.abs(scrollPosition - lineOffset) < 1;
};

View file

@ -1,46 +0,0 @@
'use strict';
describe('undo button', function () {
beforeEach(async function () {
await helper.aNewPad();
});
it('undo some typing by clicking undo button', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// get the first text element inside the editable space
const $firstTextElement = inner$('div span').first();
const originalValue = $firstTextElement.text(); // get the original value
$firstTextElement.sendkeys('foo'); // send line 1 to the pad
const modifiedValue = $firstTextElement.text(); // get the modified value
expect(modifiedValue).not.to.be(originalValue); // expect the value to change
// get clear authorship button as a variable
const $undoButton = chrome$('.buttonicon-undo');
// click the button
$undoButton.trigger('click');
await helper.waitForPromise(() => inner$('div span').first().text() === originalValue);
});
it('undo some typing using a keypress', async function () {
const inner$ = helper.padInner$;
// get the first text element inside the editable space
const $firstTextElement = inner$('div span').first();
const originalValue = $firstTextElement.text(); // get the original value
$firstTextElement.sendkeys('foo'); // send line 1 to the pad
const modifiedValue = $firstTextElement.text(); // get the modified value
expect(modifiedValue).not.to.be(originalValue); // expect the value to change
const e = new inner$.Event(helper.evtType);
e.ctrlKey = true; // Control key
e.which = 90; // z
inner$('#innerdocbody').trigger(e);
await helper.waitForPromise(() => inner$('div span').first().text() === originalValue);
});
});

View file

@ -1,146 +0,0 @@
'use strict';
describe('unordered_list.js', function () {
describe('assign unordered list', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('insert unordered list text then removes by outdent', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
const originalText = inner$('div').first().text();
const $insertunorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
$insertunorderedlistButton.trigger('click');
await helper.waitForPromise(() => {
const newText = inner$('div').first().text();
return newText === originalText && inner$('div').first().find('ul li').length === 1;
});
// remove indentation by bullet and ensure text string remains the same
chrome$('.buttonicon-outdent').trigger('click');
await helper.waitForPromise(() => inner$('div').first().text() === originalText);
});
});
describe('unassign unordered list', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('insert unordered list text then remove by clicking list again', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
const originalText = inner$('div').first().text();
const $insertunorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
$insertunorderedlistButton.trigger('click');
await helper.waitForPromise(() => {
const newText = inner$('div').first().text();
return newText === originalText && inner$('div').first().find('ul li').length === 1;
});
// remove indentation by bullet and ensure text string remains the same
$insertunorderedlistButton.trigger('click');
await helper.waitForPromise(() => inner$('div').find('ul').length !== 1);
});
});
describe('keep unordered list on enter key', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('Keeps the unordered list on enter for the new line', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
const $insertorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
$insertorderedlistButton.trigger('click');
// type a bit, make a line break and type again
const $firstTextElement = inner$('div span').first();
$firstTextElement.sendkeys('line 1');
$firstTextElement.sendkeys('{enter}');
$firstTextElement.sendkeys('line 2');
$firstTextElement.sendkeys('{enter}');
await helper.waitForPromise(() => inner$('div span').first().text().indexOf('line 2') === -1);
const $newSecondLine = inner$('div').first().next();
const hasULElement = $newSecondLine.find('ul li').length === 1;
expect(hasULElement).to.be(true);
expect($newSecondLine.text()).to.be('line 2');
});
});
describe('Pressing Tab in an UL increases and decreases indentation', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('indent and de-indent list item with keypress', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// get the first text element out of the inner iframe
const $firstTextElement = inner$('div').first();
// select this text element
$firstTextElement.sendkeys('{selectall}');
const $insertorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
$insertorderedlistButton.trigger('click');
const e = new inner$.Event(helper.evtType);
e.keyCode = 9; // tab
inner$('#innerdocbody').trigger(e);
expect(inner$('div').first().find('.list-bullet2').length === 1).to.be(true);
e.shiftKey = true; // shift
e.keyCode = 9; // tab
inner$('#innerdocbody').trigger(e);
await helper.waitForPromise(() => inner$('div').first().find('.list-bullet1').length === 1);
});
});
describe('Pressing indent/outdent button in an UL increases and decreases indentation ' +
'and bullet / ol formatting', function () {
// create a new pad before each test run
beforeEach(async function () {
await helper.aNewPad();
});
it('indent and de-indent list item with indent button', async function () {
const inner$ = helper.padInner$;
const chrome$ = helper.padChrome$;
// get the first text element out of the inner iframe
const $firstTextElement = inner$('div').first();
// select this text element
$firstTextElement.sendkeys('{selectall}');
const $insertunorderedlistButton = chrome$('.buttonicon-insertunorderedlist');
$insertunorderedlistButton.trigger('click');
const $indentButton = chrome$('.buttonicon-indent');
$indentButton.trigger('click'); // make it indented twice
expect(inner$('div').first().find('.list-bullet2').length === 1).to.be(true);
const $outdentButton = chrome$('.buttonicon-outdent');
$outdentButton.trigger('click'); // make it deindented to 1
await helper.waitForPromise(() => inner$('div').first().find('.list-bullet1').length === 1);
});
});
});

View file

@ -1,56 +0,0 @@
'use strict';
describe('urls', function () {
// Returns the first text element. Note that any change to the text element will result in the
// element being replaced with another object.
const txt = () => helper.padInner$('div').first();
before(async function () {
await helper.aNewPad();
});
beforeEach(async function () {
await helper.clearPad();
});
describe('entering a URL makes a link', function () {
for (const url of ['https://etherpad.org', 'www.etherpad.org']) {
it(url, async function () {
this.timeout(5000);
const url = 'https://etherpad.org';
await helper.edit(url);
await helper.waitForPromise(() => txt().find('a').length === 1, 2000);
const link = txt().find('a');
expect(link.attr('href')).to.be(url);
expect(link.text()).to.be(url);
});
}
});
describe('special characters inside URL', function () {
for (const char of '-:@_.,~%+/?=&#!;()[]$\'*') {
const url = `https://etherpad.org/${char}foo`;
it(url, async function () {
await helper.edit(url);
await helper.waitForPromise(() => txt().find('a').length === 1);
const link = txt().find('a');
expect(link.attr('href')).to.be(url);
expect(link.text()).to.be(url);
});
}
});
describe('punctuation after URL is ignored', function () {
for (const char of ':.,;?!)]\'*') {
const want = 'https://etherpad.org';
const input = want + char;
it(input, async function () {
await helper.edit(input);
await helper.waitForPromise(() => txt().find('a').length === 1);
const link = txt().find('a');
expect(link.attr('href')).to.be(want);
expect(link.text()).to.be(want);
});
}
});
});

654
src/tests/settings.json Normal file
View file

@ -0,0 +1,654 @@
/*
* This file must be valid JSON. But comments are allowed
*
* Please edit settings.json, not settings.json.template
*
* Please note that starting from Etherpad 1.6.0 you can store DB credentials in
* a separate file (credentials.json).
*
*
* ENVIRONMENT VARIABLE SUBSTITUTION
* =================================
*
* All the configuration values can be read from environment variables using the
* syntax "${ENV_VAR}" or "${ENV_VAR:default_value}".
*
* This is useful, for example, when running in a Docker container.
*
* DETAILED RULES:
* - If the environment variable is set to the string "true" or "false", the
* value becomes Boolean true or false.
* - If the environment variable is set to the string "null", the value
* becomes null.
* - If the environment variable is set to the string "undefined", the setting
* is removed entirely, except when used as the member of an array in which
* case it becomes null.
* - If the environment variable is set to a string representation of a finite
* number, the string is converted to that number.
* - If the environment variable is set to any other string, including the
* empty string, the value is that string.
* - If the environment variable is unset and a default value is provided, the
* value is as if the environment variable was set to the provided default:
* - "${UNSET_VAR:}" becomes the empty string.
* - "${UNSET_VAR:foo}" becomes the string "foo".
* - "${UNSET_VAR:true}" and "${UNSET_VAR:false}" become true and false.
* - "${UNSET_VAR:null}" becomes null.
* - "${UNSET_VAR:undefined}" causes the setting to be removed (or be set
* to null, if used as a member of an array).
* - If the environment variable is unset and no default value is provided,
* the value becomes null. THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF
* ETHERPAD; if you want the default value to be null, you should explicitly
* specify "null" as the default value.
*
* EXAMPLE:
* "port": "${PORT:9001}"
* "minify": "${MINIFY}"
* "skinName": "${SKIN_NAME:colibris}"
*
* Would read the configuration values for those items from the environment
* variables PORT, MINIFY and SKIN_NAME.
*
* If PORT and SKIN_NAME variables were not defined, the default values 9001 and
* "colibris" would be used.
* The configuration value "minify", on the other hand, does not have a
* designated default value. Thus, if the environment variable MINIFY were
* undefined, "minify" would be null.
*
* REMARKS:
* 1) please note that variable substitution always needs to be quoted.
*
* "port": 9001, <-- Literal values. When not using
* "minify": false substitution, only strings must be
* "skinName": "colibris" quoted. Booleans and numbers must not.
*
* "port": "${PORT:9001}" <-- CORRECT: if you want to use a variable
* "minify": "${MINIFY:true}" substitution, put quotes around its name,
* "skinName": "${SKIN_NAME}" even if the required value is a number or
* a boolean.
* Etherpad will take care of rewriting it
* to the proper type if necessary.
*
* "port": ${PORT:9001} <-- ERROR: this is not valid json. Quotes
* "minify": ${MINIFY} around variable names are missing.
* "skinName": ${SKIN_NAME}
*
* 2) Beware of undefined variables and default values: nulls and empty strings
* are different!
*
* This is particularly important for user's passwords (see the relevant
* section):
*
* "password": "${PASSW}" // if PASSW is not defined would result in password === null
* "password": "${PASSW:}" // if PASSW is not defined would result in password === ''
*
* If you want to use an empty value (null) as default value for a variable,
* simply do not set it, without putting any colons: "${ABIWORD}".
*
* 3) if you want to use newlines in the default value of a string parameter,
* use "\n" as usual.
*
* "defaultPadText" : "${DEFAULT_PAD_TEXT}Line 1\nLine 2"
*/
{
/*
* Name your instance!
*/
"title": "Etherpad",
/*
* Pathname of the favicon you want to use. If null, the skin's favicon is
* used if one is provided by the skin, otherwise the default Etherpad favicon
* is used. If this is a relative path it is interpreted as relative to the
* Etherpad root directory.
*/
"favicon": null,
/*
* Skin name.
*
* Its value has to be an existing directory under src/static/skins.
* You can write your own, or use one of the included ones:
*
* - "no-skin": an empty skin (default). This yields the unmodified,
* traditional Etherpad theme.
* - "colibris": the new experimental skin (since Etherpad 1.8), candidate to
* become the default in Etherpad 2.0
*/
"skinName": "colibris",
/*
* Skin Variants
*
* Use the UI skin variants builder at /p/test#skinvariantsbuilder
*
* For the colibris skin only, you can choose how to render the three main
* containers:
* - toolbar (top menu with icons)
* - editor (containing the text of the pad)
* - background (area outside of editor, mostly visible when using page style)
*
* For each of the 3 containers you can choose 4 color combinations:
* super-light, light, dark, super-dark.
*
* For example, to make the toolbar dark, you will include "dark-toolbar" into
* skinVariants.
*
* You can provide multiple skin variants separated by spaces. Default
* skinVariant is "super-light-toolbar super-light-editor light-background".
*
* For the editor container, you can also make it full width by adding
* "full-width-editor" variant (by default editor is rendered as a page, with
* a max-width of 900px).
*/
"skinVariants": "super-light-toolbar super-light-editor light-background",
/*
* IP and port which Etherpad should bind at.
*
* Binding to a Unix socket is also supported: just use an empty string for
* the ip, and put the full path to the socket in the port parameter.
*
* EXAMPLE USING UNIX SOCKET:
* "ip": "", // <-- has to be an empty string
* "port" : "/somepath/etherpad.socket", // <-- path to a Unix socket
*/
"ip": "0.0.0.0",
"port": 9001,
/*
* Option to hide/show the settings.json in admin page.
*
* Default option is set to true
*/
"showSettingsInAdminPage": true,
/*
* Node native SSL support
*
* This is disabled by default.
* Make sure to have the minimum and correct file access permissions set so
* that the Etherpad server can access them
*/
/*
"ssl" : {
"key" : "/path-to-your/epl-server.key",
"cert" : "/path-to-your/epl-server.crt",
"ca": ["/path-to-your/epl-intermediate-cert1.crt", "/path-to-your/epl-intermediate-cert2.crt"]
},
*/
/*
* The type of the database.
*
* You can choose between many DB drivers, for example: dirty, postgres,
* sqlite, mysql.
*
* You shouldn't use "dirty" for for anything else than testing or
* development.
*
*
* Database specific settings are dependent on dbType, and go in dbSettings.
* Remember that since Etherpad 1.6.0 you can also store this information in
* credentials.json.
*
* For a complete list of the supported drivers, please refer to:
* https://www.npmjs.com/package/ueberdb2
*/
"dbType": "dirty",
"dbSettings": {
"filename": "var/dirty.db"
},
/*
* An Example of MySQL Configuration (commented out).
*
* See: https://github.com/ether/etherpad-lite/wiki/How-to-use-Etherpad-Lite-with-MySQL
*/
/*
"dbType" : "mysql",
"dbSettings" : {
"user": "etherpaduser",
"host": "localhost",
"port": 3306,
"password": "PASSWORD",
"database": "etherpad_lite_db",
"charset": "utf8mb4"
},
*/
/*
* The default text of a pad
*/
"defaultPadText" : "Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https:\/\/etherpad.org\n",
/*
* Default Pad behavior.
*
* Change them if you want to override.
*/
"padOptions": {
"noColors": false,
"showControls": true,
"showChat": true,
"showLineNumbers": true,
"useMonospaceFont": false,
"userName": null,
"userColor": null,
"rtl": false,
"alwaysShowChat": false,
"chatAndUsers": false,
"lang": null
},
/*
* Pad Shortcut Keys
*/
"padShortcutEnabled" : {
"altF9": true, /* focus on the File Menu and/or editbar */
"altC": true, /* focus on the Chat window */
"cmdShift2": true, /* shows a gritter popup showing a line author */
"delete": true,
"return": true,
"esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */
"cmdS": true, /* save a revision */
"tab": true, /* indent */
"cmdZ": true, /* undo/redo */
"cmdY": true, /* redo */
"cmdI": true, /* italic */
"cmdB": true, /* bold */
"cmdU": true, /* underline */
"cmd5": true, /* strike through */
"cmdShiftL": true, /* unordered list */
"cmdShiftN": true, /* ordered list */
"cmdShift1": true, /* ordered list */
"cmdShiftC": true, /* clear authorship */
"cmdH": true, /* backspace */
"ctrlHome": true, /* scroll to top of pad */
"pageUp": true,
"pageDown": true
},
/*
* Should we suppress errors from being visible in the default Pad Text?
*/
"suppressErrorsInPadText": false,
/*
* If this option is enabled, a user must have a session to access pads.
* This effectively allows only group pads to be accessed.
*/
"requireSession": false,
/*
* Users may edit pads but not create new ones.
*
* Pad creation is only via the API.
* This applies both to group pads and regular pads.
*/
"editOnly": false,
/*
* If true, all css & js will be minified before sending to the client.
*
* This will improve the loading performance massively, but makes it difficult
* to debug the javascript/css
*/
"minify": true,
/*
* How long may clients use served javascript code (in seconds)?
*
* Not setting this may cause problems during deployment.
* Set to 0 to disable caching.
*/
"maxAge": 21600, // 60 * 60 * 6 = 6 hours
/*
* Absolute path to the Abiword executable.
*
* Abiword is needed to get advanced import/export features of pads. Setting
* it to null disables Abiword and will only allow plain text and HTML
* import/exports.
*/
"abiword": null,
/*
* This is the absolute path to the soffice executable.
*
* LibreOffice can be used in lieu of Abiword to export pads.
* Setting it to null disables LibreOffice exporting.
*/
"soffice": null,
/*
* Allow import of file types other than the supported ones:
* txt, doc, docx, rtf, odt, html & htm
*/
"allowUnknownFileEnds": true,
/*
* This setting is used if you require authentication of all users.
*
* Note: "/admin" always requires authentication.
*/
"requireAuthentication": false,
/*
* Require authorization by a module, or a user with is_admin set, see below.
*/
"requireAuthorization": false,
/*
* When you use NGINX or another proxy/load-balancer set this to true.
*
* This is especially necessary when the reverse proxy performs SSL
* termination, otherwise the cookies will not have the "secure" flag.
*
* The other effect will be that the logs will contain the real client's IP,
* instead of the reverse proxy's IP.
*/
"trustProxy": false,
/*
* Settings controlling the session cookie issued by Etherpad.
*/
"cookie": {
/*
* How often (in milliseconds) the key used to sign the express_sid cookie
* should be rotated. Long rotation intervals reduce signature verification
* overhead (because there are fewer historical keys to check) and database
* load (fewer historical keys to store, and less frequent queries to
* get/update the keys). Short rotation intervals are slightly more secure.
*
* Multiple Etherpad processes sharing the same database (table) is
* supported as long as the clock sync error is significantly less than this
* value.
*
* Key rotation can be disabled (not recommended) by setting this to 0 or
* null, or by disabling session expiration (see sessionLifetime).
*/
"keyRotationInterval": 86400000, // = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s
/*
* Value of the SameSite cookie property. "Lax" is recommended unless
* Etherpad will be embedded in an iframe from another site, in which case
* this must be set to "None". Note: "None" will not work (the browser will
* not send the cookie to Etherpad) unless https is used to access Etherpad
* (either directly or via a reverse proxy with "trustProxy" set to true).
*
* "Strict" is not recommended because it has few security benefits but
* significant usability drawbacks vs. "Lax". See
* https://stackoverflow.com/q/41841880 for discussion.
*/
"sameSite": "Lax",
/*
* How long (in milliseconds) after navigating away from Etherpad before the
* user is required to log in again. (The express_sid cookie is set to
* expire at time now + sessionLifetime when first created, and its
* expiration time is periodically refreshed to a new now + sessionLifetime
* value.) If requireAuthentication is false then this value does not really
* matter.
*
* The "best" value depends on your users' usage patterns and the amount of
* convenience you desire. A long lifetime is more convenient (users won't
* have to log back in as often) but has some drawbacks:
* - It increases the amount of state kept in the database.
* - It might weaken security somewhat: The cookie expiration is refreshed
* indefinitely without consulting authentication or authorization
* hooks, so once a user has accessed a pad, the user can continue to
* use the pad until the user leaves for longer than sessionLifetime.
* - More historical keys (sessionLifetime / keyRotationInterval) must be
* checked when verifying signatures.
*
* Session lifetime can be set to infinity (not recommended) by setting this
* to null or 0. Note that if the session does not expire, most browsers
* will delete the cookie when the browser exits, but a session record is
* kept in the database forever.
*/
"sessionLifetime": 864000000, // = 10d * 24h/d * 60m/h * 60s/m * 1000ms/s
/*
* How long (in milliseconds) before the expiration time of an active user's
* session is refreshed (to now + sessionLifetime). This setting affects the
* following:
* - How often a new session expiration time will be written to the
* database.
* - How often each user's browser will ping the Etherpad server to
* refresh the expiration time of the session cookie.
*
* High values reduce the load on the database and the load from browsers,
* but can shorten the effective session lifetime if Etherpad is restarted
* or the user navigates away.
*
* Automatic session refreshes can be disabled (not recommended) by setting
* this to null.
*/
"sessionRefreshInterval": 86400000 // = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s
},
/*
* Privacy: disable IP logging
*/
"disableIPlogging": false,
/*
* Time (in seconds) to automatically reconnect pad when a "Force reconnect"
* message is shown to user.
*
* Set to 0 to disable automatic reconnection.
*/
"automaticReconnectionTimeout": 0,
/*
* By default, when caret is moved out of viewport, it scrolls the minimum
* height needed to make this line visible.
*/
"scrollWhenFocusLineIsOutOfViewport": {
/*
* Percentage of viewport height to be additionally scrolled.
*
* E.g.: use "percentage.editionAboveViewport": 0.5, to place caret line in
* the middle of viewport, when user edits a line above of the
* viewport
*
* Set to 0 to disable extra scrolling
*/
"percentage": {
"editionAboveViewport": 0,
"editionBelowViewport": 0
},
/*
* Time (in milliseconds) used to animate the scroll transition.
* Set to 0 to disable animation
*/
"duration": 0,
/*
* Flag to control if it should scroll when user places the caret in the
* last line of the viewport
*/
"scrollWhenCaretIsInTheLastLineOfViewport": false,
/*
* Percentage of viewport height to be additionally scrolled when user
* presses arrow up in the line of the top of the viewport.
*
* Set to 0 to let the scroll to be handled as default by Etherpad
*/
"percentageToScrollWhenUserPressesArrowUp": 0
},
/*
* User accounts. These accounts are used by:
* - default HTTP basic authentication if no plugin handles authentication
* - some but not all authentication plugins
* - some but not all authorization plugins
*
* User properties:
* - password: The user's password. Some authentication plugins will ignore
* this.
* - is_admin: true gives access to /admin. Defaults to false. If you do not
* uncomment this, /admin will not be available!
* - readOnly: If true, this user will not be able to create new pads or
* modify existing pads. Defaults to false.
* - canCreate: If this is true and readOnly is false, this user can create
* new pads. Defaults to true.
*
* Authentication and authorization plugins may define additional properties.
*
* WARNING: passwords should not be stored in plaintext in this file.
* If you want to mitigate this, please install ep_hash_auth and
* follow the section "secure your installation" in README.md
*/
"users": {
"admin": {
// 1) "password" can be replaced with "hash" if you install ep_hash_auth
// 2) please note that if password is null, the user will not be created
"password": "changeme1",
"is_admin": true
},
"user": {
// 1) "password" can be replaced with "hash" if you install ep_hash_auth
// 2) please note that if password is null, the user will not be created
"password": "changeme1",
"is_admin": false
}
},
/*
* Restrict socket.io transport methods
*/
"socketTransportProtocols" : ["websocket", "polling"],
"socketIo": {
/*
* Maximum permitted client message size (in bytes). All messages from
* clients that are larger than this will be rejected. Large values make it
* possible to paste large amounts of text, and plugins may require a larger
* value to work properly, but increasing the value increases susceptibility
* to denial of service attacks (malicious clients can exhaust memory).
*/
"maxHttpBufferSize": 1000000
},
/*
* Allow Load Testing tools to hit the Etherpad Instance.
*
* WARNING: this will disable security on the instance.
*/
"loadTest": false,
/**
* Disable dump of objects preventing a clean exit
*/
"dumpOnUncleanExit": false,
/*
* Disable indentation on new line when previous line ends with some special
* chars (':', '[', '(', '{')
*/
/*
"indentationOnNewLine": false,
*/
/*
* From Etherpad 1.8.3 onwards, import and export of pads is always rate
* limited.
*
* The default is to allow at most 10 requests per IP in a 90 seconds window.
* After that the import/export request is rejected.
*
* See https://github.com/nfriedly/express-rate-limit for more options
*/
"importExportRateLimiting": {
// duration of the rate limit window (milliseconds)
"windowMs": 90000,
// maximum number of requests per IP to allow during the rate limit window
"max": 10
},
/*
* From Etherpad 1.8.3 onwards, the maximum allowed size for a single imported
* file is always bounded.
*
* File size is specified in bytes. Default is 50 MB.
*/
"importMaxFileSize": 52428800, // 50 * 1024 * 1024
/*
* From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited
*
* The default is to allow at most 10 changes per IP in a 1 second window.
* After that the change is rejected.
*
* See https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#websocket-single-connection-prevent-flooding for more options
*/
"commitRateLimiting": {
// duration of the rate limit window (seconds)
"duration": 1,
// maximum number of changes per IP to allow during the rate limit window
"points": 10
},
/*
* Toolbar buttons configuration.
*
* Uncomment to customize.
*/
/*
"toolbar": {
"left": [
["bold", "italic", "underline", "strikethrough"],
["orderedlist", "unorderedlist", "indent", "outdent"],
["undo", "redo"],
["clearauthorship"]
],
"right": [
["importexport", "timeslider", "savedrevision"],
["settings", "embed"],
["showusers"]
],
"timeslider": [
["timeslider_export", "timeslider_returnToPad"]
]
},
*/
/*
* Expose Etherpad version in the web interface and in the Server http header.
*
* Do not enable on production machines.
*/
"exposeVersion": false,
/*
* The log level we are using.
*
* Valid values: DEBUG, INFO, WARN, ERROR
*/
"loglevel": "INFO",
/* Override any strings found in locale directories */
"customLocaleStrings": {},
/* Disable Admin UI tests */
"enableAdminUITests": true,
/*
* Enable/Disable case-insensitive pad names.
*/
"lowerCasePadIds": false
}