Skip to content

Commit efb71bf

Browse files
committed
Add e2e test for editor
1 parent 36ef5a2 commit efb71bf

File tree

10 files changed

+380
-74
lines changed

10 files changed

+380
-74
lines changed

.github/workflows/e2e-tests.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ name: E2E Tests
22
permissions: {}
33

44
on:
5-
# pull_request:
5+
# Temporarily enabling pull_request to test before merging
6+
pull_request:
67
# merge_group:
78
workflow_dispatch:
89
schedule:

src/SIL.XForge.Scripture/ClientApp/e2e/e2e-utils.ts

Lines changed: 125 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/// <reference lib="dom" />
12
import { Browser, chromium, Locator, Page, PageScreenshotOptions } from 'npm:playwright';
23
import { expect } from 'npm:playwright/test';
34
import locales from '../../locales.json' with { type: 'json' };
@@ -83,6 +84,59 @@ export async function ensureOnMyProjectsPage(page: Page): Promise<void> {
8384
await page.waitForURL(url => url.pathname === '/projects');
8485
}
8586

87+
/**
88+
* Connect to a project, deleting it first if it already exists.
89+
* @param page The Playwright page object.
90+
* @param shortName The short name of the project to connect.
91+
* @param source Optional short name of source text to select when connecting the project.
92+
*/
93+
export async function freshlyConnectProject(page: Page, shortName: string, source?: string): Promise<void> {
94+
await ensureOnMyProjectsPage(page);
95+
const connected = await isProjectConnected(page, shortName);
96+
const joined = await isProjectJoined(page, shortName);
97+
98+
if (connected && !joined) await ensureJoinedOrConnectedToProject(page, shortName);
99+
100+
if (connected) {
101+
await ensureNavigatedToProject(page, shortName);
102+
await deleteProject(page, shortName);
103+
}
104+
105+
await connectProject(page, shortName, source);
106+
}
107+
108+
/**
109+
* Connects a project by clicking the "Connect" button on the My Projects page. The project must not already be
110+
* connected.
111+
* @param page The Playwright page object.
112+
* @param shortName The short name of the project to connect.
113+
* @param source Optional short name of source text to select when connecting the project.
114+
* @returns A promise that resolves when the project is connected.
115+
*/
116+
export async function connectProject(page: Page, shortName: string, source?: string): Promise<void> {
117+
await ensureOnMyProjectsPage(page);
118+
await page
119+
.locator(`.user-unconnected-project:has-text("${shortName}")`)
120+
.getByRole('link', { name: 'Connect' })
121+
.click();
122+
123+
await page.waitForURL(url => /^\/connect-project/.test(url.pathname));
124+
125+
if (source != null) {
126+
await page.getByRole('combobox', { name: 'Source text (optional)' }).click();
127+
// FIXME(application-bug) The source text combobox is not always ready to be used immediately after opening the
128+
// connect project page because resources are still loading, and if they load after the user types, the filtering
129+
// doesn't happen until further input.
130+
await page.waitForTimeout(5000);
131+
await page.getByRole('combobox', { name: 'Source text (optional)' }).click();
132+
await page.keyboard.type(source);
133+
await page.getByRole('option', { name: `${source} - ` }).click();
134+
}
135+
136+
await page.getByRole('button', { name: 'Connect' }).click();
137+
await waitForNavigationToProjectPage(page, E2E_SYNC_DEFAULT_TIMEOUT);
138+
}
139+
86140
export async function ensureJoinedOrConnectedToProject(page: Page, shortName: string): Promise<void> {
87141
// Wait for the project to be listed as connected or not connected
88142
await Promise.race([
@@ -96,17 +150,22 @@ export async function ensureJoinedOrConnectedToProject(page: Page, shortName: st
96150
// If not connected, click on the Connect or Join
97151
const project = await page.locator(`.user-unconnected-project:has-text("${shortName}")`);
98152

99-
if (await project.getByRole('button', { name: 'Join' }).isVisible()) {
100-
await project.getByRole('button', { name: 'Join' }).click();
101-
} else if (await project.getByRole('link', { name: 'Connect' }).isVisible()) {
102-
await project.getByRole('link', { name: 'Connect' }).click();
153+
const connectLocator = project.getByRole('link', { name: 'Connect' });
154+
const joinLocator = project.getByRole('button', { name: 'Join' });
155+
156+
await expect(joinLocator.or(connectLocator)).toBeVisible();
157+
158+
if (await joinLocator.isVisible()) {
159+
await joinLocator.click();
160+
} else if (await connectLocator.isVisible()) {
161+
await connectLocator.click();
103162
await page.waitForURL(url => /^\/connect-project/.test(url.pathname));
104163
await page.getByRole('button', { name: 'Connect' }).click();
105164
} else {
106165
throw new Error('Neither Join nor Connect button found');
107166
}
108167

109-
await page.waitForURL(url => /\/projects\/[a-z0-9]+/.test(url.pathname), { timeout: E2E_SYNC_DEFAULT_TIMEOUT });
168+
await waitForNavigationToProjectPage(page, E2E_SYNC_DEFAULT_TIMEOUT);
110169
}
111170

112171
export async function screenshot(
@@ -184,7 +243,11 @@ export async function ensureNavigatedToProject(page: Page, shortName: string): P
184243
await ensureOnMyProjectsPage(page);
185244

186245
await page.getByRole('button', { name: shortName }).click();
187-
await page.waitForURL(url => /\/projects\/[a-z0-9]+/.test(url.pathname));
246+
await waitForNavigationToProjectPage(page);
247+
}
248+
249+
export async function waitForNavigationToProjectPage(page: Page, timeout?: number): Promise<void> {
250+
await page.waitForURL(url => /\/projects\/[a-z0-9]+/.test(url.pathname), { timeout });
188251
}
189252

190253
export async function deleteProject(page: Page, shortName: string): Promise<void> {
@@ -238,15 +301,14 @@ export async function enableDeveloperMode(page: Page, options = { closeMenu: fal
238301

239302
export async function installMouseFollower(page: Page): Promise<void> {
240303
const animationMs = Math.min(preset.defaultUserDelay, 200);
241-
const document: any = {};
242304
await page.evaluate(animationMs => {
243305
const arrowSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M0 55.2L0 426c0 12.2 9.9 22 22 22c6.3 0 12.4-2.7 16.6-7.5L121.2 346l58.1 116.3c7.9 15.8 27.1 22.2 42.9 14.3s22.2-27.1 14.3-42.9L179.8 320l118.1 0c12.2 0 22.1-9.9 22.1-22.1c0-6.3-2.7-12.3-7.4-16.5L38.6 37.9C34.3 34.1 28.9 32 23.2 32C10.4 32 0 42.4 0 55.2z"/></svg>`;
244306
// Work around the inability to directly create an SVG element
245307
const span = document.createElement('span');
246308
span.innerHTML = arrowSvg;
247-
const mouseFollower = span.firstChild;
309+
const mouseFollower = span.firstElementChild as HTMLElement;
248310
mouseFollower.style.position = 'absolute';
249-
mouseFollower.style['z-index'] = 1000000;
311+
mouseFollower.style.zIndex = '1000000';
250312
mouseFollower.style.width = '30px';
251313
// Add a white border around the arrow for contrast with dark backgrounds
252314
mouseFollower.style.filter =
@@ -321,13 +383,12 @@ export function isRootUrl(url: string): boolean {
321383
}
322384

323385
async function setLocatorToValue(page: Page, locator: string, value: string): Promise<void> {
324-
// Trick TypeScript into not complaining that the document isn't defined
325-
// The function is actually evaluated in the browser, not in Deno
326-
// deno-lint-ignore no-explicit-any
327-
const document = {} as any;
328386
return await page.evaluate(
329387
({ locator, value }) => {
330-
document.querySelector(locator).value = value;
388+
const element = document.querySelector(locator);
389+
if (element == null) throw new Error(`Element not found for locator: ${locator}`);
390+
// @ts-ignore Property 'value' does not exist on type 'Element'.
391+
element.value = value;
331392
},
332393
{ locator, value }
333394
);
@@ -444,3 +505,53 @@ export class Utils {
444505
return n.toString().padStart(2, '0');
445506
}
446507
}
508+
509+
/**
510+
* Moves the caret to the end of the element using browser APIs.
511+
* Supports input, textarea, or contenteditable elements.
512+
*/
513+
export async function moveCaretToEndOfSegment(page: Page, locator: Locator): Promise<void> {
514+
// Focus the element
515+
await locator.focus();
516+
517+
// Move caret to the end using browser APIs
518+
await locator.evaluate((el: HTMLElement) => {
519+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
520+
const len: number = el.value.length;
521+
el.setSelectionRange(len, len);
522+
} else if (el.isContentEditable) {
523+
const range = document.createRange();
524+
range.selectNodeContents(el);
525+
range.collapse(false);
526+
const sel = window.getSelection();
527+
if (sel != null) {
528+
sel.removeAllRanges();
529+
sel.addRange(range);
530+
}
531+
}
532+
});
533+
}
534+
535+
/**
536+
* Deletes all text in the element by moving the caret to the end and pressing Backspace repeatedly.
537+
* Supports input, textarea, or contenteditable elements.
538+
*/
539+
export async function deleteAllTextInSegment(page: Page, locator: Locator): Promise<void> {
540+
// Move caret to the end first
541+
await moveCaretToEndOfSegment(page, locator);
542+
543+
// Get the length of the text/content
544+
const length: number = await locator.evaluate((el: HTMLElement): number => {
545+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
546+
return el.value.length;
547+
} else if (el.isContentEditable) {
548+
return el.textContent?.length ?? 0;
549+
}
550+
return 0;
551+
});
552+
553+
// Press Backspace repeatedly to delete all text
554+
for (let i = 0; i < length; i++) {
555+
await page.keyboard.press('Backspace');
556+
}
557+
}

src/SIL.XForge.Scripture/ClientApp/e2e/e2e.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ try {
5353
if (testFn == null) throw new Error(`Test ${test} not found`);
5454
const attempts = Math.min(numberOfTimesToAttemptTest(test), preset.maxTries ?? Number.POSITIVE_INFINITY);
5555

56-
console.log(`Running test ${test} with up to ${attempts} attempts`);
56+
console.log(`%cRunning test ${test} with up to ${attempts} attempts`, "color: blue");
5757
let reRun = false;
5858
for (let i = 0; i < attempts; i++) {
5959
try {

src/SIL.XForge.Scripture/ClientApp/e2e/record-mobile-checking-videos.mts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#!/usr/bin/env -S deno run --allow-env --allow-sys --allow-read --allow-write --allow-run
2+
/// <reference lib="dom" />
3+
24
import { chromium, devices, expect } from "npm:@playwright/test";
35
import { Page } from "npm:playwright";
46
import locales from "../../locales.json" with { type: "json" };
@@ -7,11 +9,6 @@ import locales from "../../locales.json" with { type: "json" };
79

810
const typingDelayFactor = 1; // reduce to 0 to speed up typing
911

10-
// Trick TypeScript into not complaining that the document isn't defined for functions that are actually evaluated in
11-
// the browser, not in Deno.
12-
// deno-lint-ignore no-explicit-any
13-
const document = {} as any;
14-
1512
async function injectBlinkAnimation(page: Page) {
1613
await page.evaluate(() => {
1714
const style = document.createElement("style");
@@ -29,19 +26,18 @@ async function highlightElement(page: Page, selector: string) {
2926
await page.evaluate(selector => {
3027
const div = document.createElement("div");
3128
div.id = "element-highlight";
32-
const rect = document.querySelector(selector).getBoundingClientRect();
33-
div.style = `
34-
position: absolute;
35-
top: ${rect.top}px;
36-
left: ${rect.left}px;
37-
width: ${rect.width}px;
38-
height: ${rect.height}px;
39-
z-index: 1000;
40-
outline: 3px dashed red;
41-
border-radius: 10px;
42-
scale: 1.1;
43-
animation: blink-animation 0.5s ease infinite;
44-
`;
29+
const rect = document.querySelector(selector)?.getBoundingClientRect();
30+
if (!rect) throw new Error(`Element not found for selector: ${selector}`);
31+
div.style.position = "absolute";
32+
div.style.top = `${rect.top}px`;
33+
div.style.left = `${rect.left}px`;
34+
div.style.width = `${rect.width}px`;
35+
div.style.height = `${rect.height}px`;
36+
div.style.zIndex = "1000";
37+
div.style.outline = "3px dashed red";
38+
div.style.borderRadius = "10px";
39+
div.style.scale = "1.1";
40+
div.style.animation = "blink-animation 0.5s ease infinite";
4541
document.body.appendChild(div);
4642
}, selector);
4743
}

src/SIL.XForge.Scripture/ClientApp/e2e/test-definitions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BrowserType, Page } from 'npm:playwright';
22
import { ScreenshotContext } from './e2e-globals.ts';
33
import secrets from './secrets.json' with { type: 'json' };
44
import { communityChecking } from './workflows/community-checking.ts';
5+
import { editTranslation } from './workflows/edit-translation.ts';
56
import { generateDraft } from './workflows/generate-draft.ts';
67
import { localizedScreenshots } from './workflows/localized-screenshots.ts';
78
import { runSmokeTests, traverseHomePageAndLoginPage } from './workflows/smoke-tests.mts';
@@ -21,5 +22,8 @@ export const tests = {
2122
},
2223
generate_draft: async (_engine: BrowserType, page: Page, screenshotContext: ScreenshotContext) => {
2324
await generateDraft(page, screenshotContext, secrets.users[0]);
25+
},
26+
edit_translation: async (_engine: BrowserType, page: Page, screenshotContext: ScreenshotContext) => {
27+
await editTranslation(page, screenshotContext, secrets.users[0]);
2428
}
2529
} as const;
Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
{
22
"smoke_tests": {
3-
"success": 12,
3+
"success": 13,
44
"failure": 0
55
},
66
"home_and_login": {
7-
"success": 46,
7+
"success": 50,
88
"failure": 0
99
},
1010
"generate_draft": {
11-
"success": 52,
12-
"failure": 6
13-
},
14-
"community_checking": {
1511
"success": 13,
1612
"failure": 0
1713
},
14+
"community_checking": {
15+
"success": 39,
16+
"failure": 2
17+
},
1818
"localized_screenshots": {
19-
"success": 46,
19+
"success": 31,
20+
"failure": 0
21+
},
22+
"edit_translation": {
23+
"success": 13,
2024
"failure": 0
2125
}
2226
}

src/SIL.XForge.Scripture/ClientApp/e2e/user-emulator.mts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,32 @@
1+
/// <reference lib="dom" />
12
import { Locator, Page } from "npm:playwright";
23
import { preset } from "./e2e-globals.ts";
34

45
export class UserEmulator {
56
constructor(private readonly page: Page) {}
67

78
async info(message: string, time = 3_000): Promise<void> {
8-
const document: any = {};
99
await this.page.evaluate(message => {
1010
const div = document.createElement("div");
1111
div.id = "info-message";
1212
div.textContent = message;
1313
div.style.position = "absolute";
14-
div.style.top = 0;
15-
div.style.left = 0;
16-
div.style.right = 0;
17-
div.style.bottom = 0;
18-
div.style["z-index"] = 10000000;
14+
div.style.top = "0";
15+
div.style.left = "0";
16+
div.style.right = "0";
17+
div.style.bottom = "0";
18+
div.style.zIndex = "1000000";
1919
div.style.background = "black";
2020
div.style.color = "white";
21-
div.style["font-family"] = "Roboto";
22-
div.style["font-size"] = "3em";
21+
div.style.fontFamily = "Roboto";
22+
div.style.fontSize = "3em";
2323
div.style.display = "flex";
24-
div.style["align-items"] = "center";
25-
div.style["justify-content"] = "center";
24+
div.style.alignItems = "center";
25+
div.style.justifyContent = "center";
2626

2727
document.body.appendChild(div);
2828
}, message);
29-
await this.page.waitForTimeout(time);
29+
await this.page.waitForTimeout(preset.defaultUserDelay === 0 ? 0 : time);
3030
await this.page.evaluate(() => document.getElementById("info-message")?.remove());
3131
}
3232

@@ -51,15 +51,17 @@ export class UserEmulator {
5151
}
5252

5353
async type(text: string): Promise<void> {
54-
// wait for the focused element to be an input
55-
const document = {} as any;
56-
await this.page.waitForFunction(() => ["INPUT", "TEXTAREA"].includes(document.activeElement.tagName));
54+
// wait for the focused element to be an input, textarea, or contenteditable
55+
await this.page.waitForFunction(() => {
56+
const el = document.activeElement as HTMLElement | null;
57+
return el != null && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable === true);
58+
});
5759
await this.page.keyboard.type(text, { delay: this.typingDelay });
5860
await this.afterAction();
5961
}
6062

6163
async clearField(locator: Locator): Promise<void> {
62-
const characterCount = await locator.evaluate(el => el.value.length);
64+
const characterCount = await locator.evaluate((el: HTMLInputElement) => el.value.length);
6365
for (let i = 0; i <= characterCount; i++) {
6466
await this.page.waitForTimeout(this.typingDelay / 2);
6567
await this.page.keyboard.press("Backspace");

0 commit comments

Comments
 (0)