1
+ /// <reference lib="dom" />
1
2
import { Browser , chromium , Locator , Page , PageScreenshotOptions } from 'npm:playwright' ;
2
3
import { expect } from 'npm:playwright/test' ;
3
4
import locales from '../../locales.json' with { type : 'json' } ;
@@ -83,6 +84,59 @@ export async function ensureOnMyProjectsPage(page: Page): Promise<void> {
83
84
await page . waitForURL ( url => url . pathname === '/projects' ) ;
84
85
}
85
86
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 => / ^ \/ c o n n e c t - p r o j e c t / . 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
+
86
140
export async function ensureJoinedOrConnectedToProject ( page : Page , shortName : string ) : Promise < void > {
87
141
// Wait for the project to be listed as connected or not connected
88
142
await Promise . race ( [
@@ -96,17 +150,22 @@ export async function ensureJoinedOrConnectedToProject(page: Page, shortName: st
96
150
// If not connected, click on the Connect or Join
97
151
const project = await page . locator ( `.user-unconnected-project:has-text("${ shortName } ")` ) ;
98
152
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 ( ) ;
103
162
await page . waitForURL ( url => / ^ \/ c o n n e c t - p r o j e c t / . test ( url . pathname ) ) ;
104
163
await page . getByRole ( 'button' , { name : 'Connect' } ) . click ( ) ;
105
164
} else {
106
165
throw new Error ( 'Neither Join nor Connect button found' ) ;
107
166
}
108
167
109
- await page . waitForURL ( url => / \/ p r o j e c t s \/ [ a - z 0 - 9 ] + / . test ( url . pathname ) , { timeout : E2E_SYNC_DEFAULT_TIMEOUT } ) ;
168
+ await waitForNavigationToProjectPage ( page , E2E_SYNC_DEFAULT_TIMEOUT ) ;
110
169
}
111
170
112
171
export async function screenshot (
@@ -184,7 +243,11 @@ export async function ensureNavigatedToProject(page: Page, shortName: string): P
184
243
await ensureOnMyProjectsPage ( page ) ;
185
244
186
245
await page . getByRole ( 'button' , { name : shortName } ) . click ( ) ;
187
- await page . waitForURL ( url => / \/ p r o j e c t s \/ [ a - z 0 - 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 => / \/ p r o j e c t s \/ [ a - z 0 - 9 ] + / . test ( url . pathname ) , { timeout } ) ;
188
251
}
189
252
190
253
export async function deleteProject ( page : Page , shortName : string ) : Promise < void > {
@@ -238,15 +301,14 @@ export async function enableDeveloperMode(page: Page, options = { closeMenu: fal
238
301
239
302
export async function installMouseFollower ( page : Page ) : Promise < void > {
240
303
const animationMs = Math . min ( preset . defaultUserDelay , 200 ) ;
241
- const document : any = { } ;
242
304
await page . evaluate ( animationMs => {
243
305
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>` ;
244
306
// Work around the inability to directly create an SVG element
245
307
const span = document . createElement ( 'span' ) ;
246
308
span . innerHTML = arrowSvg ;
247
- const mouseFollower = span . firstChild ;
309
+ const mouseFollower = span . firstElementChild as HTMLElement ;
248
310
mouseFollower . style . position = 'absolute' ;
249
- mouseFollower . style [ 'z-index' ] = 1000000 ;
311
+ mouseFollower . style . zIndex = ' 1000000' ;
250
312
mouseFollower . style . width = '30px' ;
251
313
// Add a white border around the arrow for contrast with dark backgrounds
252
314
mouseFollower . style . filter =
@@ -321,13 +383,12 @@ export function isRootUrl(url: string): boolean {
321
383
}
322
384
323
385
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 ;
328
386
return await page . evaluate (
329
387
( { 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 ;
331
392
} ,
332
393
{ locator, value }
333
394
) ;
@@ -444,3 +505,53 @@ export class Utils {
444
505
return n . toString ( ) . padStart ( 2 , '0' ) ;
445
506
}
446
507
}
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
+ }
0 commit comments