@@ -19,6 +19,14 @@ Before({ tags: "@long-scenario" }, async () => {
1919 test . info ( ) . setTimeout ( 15 * 60_000 )
2020} )
2121
22+ // Try-exercise scenarios walk 11 question types with a draft-save on every
23+ // "Next question". CI spent the whole 90s budget still on question 10 with
24+ // the Next button stuck on "Saving". Four minutes is enough for the saves
25+ // without using the 15-minute specialCase1 budget.
26+ Before ( { tags : "@slow-scenario" } , async ( ) => {
27+ test . info ( ) . setTimeout ( 4 * 60_000 )
28+ } )
29+
2230// Mirrors Mink's `files_path` (tests/behat/behat.yml: "%paths.base%/../../",
2331// i.e. repo root) — attachFileToField() paths in .feature files are relative
2432// to repo root, not this steps file. tests/playwright/steps -> repo root.
@@ -181,25 +189,31 @@ async function loginAs(page: Page, username: string) {
181189 // fix targets.
182190 await page . goto ( "/login" )
183191 await page . waitForLoadState ( "domcontentloaded" )
184- if ( ! ( await page . locator ( "#login" ) . isVisible ( ) . catch ( ( ) => false ) ) ) {
192+ const loginField = page . locator ( "#login" )
193+ const logoutLink = page . locator ( 'a[href="/logout"]' )
194+ // Authenticated visits to /login redirect to home. A one-shot #login
195+ // isVisible() can catch the form during that flash, then #password.click()
196+ // retries on a detached node for the rest of the 15-minute @long-scenario
197+ // budget (specialCase1 "Initial platform searches" / extra-fields).
198+ await Promise . race ( [
199+ loginField . waitFor ( { state : "visible" , timeout : 8_000 } ) ,
200+ logoutLink . waitFor ( { state : "visible" , timeout : 8_000 } ) ,
201+ ] ) . catch ( ( ) => { } )
202+ if ( await logoutLink . isVisible ( ) . catch ( ( ) => false ) ) {
185203 await page . goto ( "/logout" )
186204 await page . goto ( "/login" )
187205 await page . waitForLoadState ( "domcontentloaded" )
188206 }
189- const loginField = page . locator ( "#login" )
190207 await expect ( loginField ) . toBeVisible ( { timeout : 15_000 } )
191- await loginField . click ( )
192- await loginField . fill ( username )
193- const passwordField = page . locator ( "#password" )
194- await passwordField . click ( )
195- await passwordField . fill ( username )
208+ await loginField . fill ( username , { timeout : 10_000 } )
209+ await page . locator ( "#password" ) . fill ( username , { timeout : 10_000 } )
196210 // Scope to the login form. A broader `button:has-text('Sign in')` can race
197211 // a SPA redirect after fill: the form is already gone, click() then waits
198212 // the rest of the test timeout for a Sign in button that will never return
199213 // (webserverLoad's non-admin scenario: snapshot already showed Sign out).
200214 const signIn = page . locator ( "form.login-section__form button[type='submit']" )
201215 if ( await signIn . isVisible ( ) . catch ( ( ) => false ) ) {
202- await signIn . click ( )
216+ await signIn . click ( { timeout : 15_000 } ) . catch ( ( ) => { } )
203217 }
204218 // Do not waitForURL({ waitUntil: "load" }): SPA login never fires load, and
205219 // that hung the full test timeout. Do not use "commit" either: a CI run
@@ -1292,13 +1306,16 @@ async function gotoReliably(page: Page, path: string, maxAttempts = 5) {
12921306async function loginAsAdminOnFreshPage ( browser : import ( "@playwright/test" ) . Browser , baseURL ?: string ) {
12931307 const page = await ( await browser . newContext ( { baseURL } ) ) . newPage ( )
12941308 await page . goto ( "/login" )
1295- await page . locator ( "#login" ) . fill ( "admin" )
1296- await page . locator ( "#password" ) . fill ( "admin" )
1309+ await page . locator ( "#login" ) . fill ( "admin" , { timeout : 10_000 } )
1310+ await page . locator ( "#password" ) . fill ( "admin" , { timeout : 10_000 } )
12971311 const signIn = page . locator ( "form.login-section__form button[type='submit']" )
12981312 if ( await signIn . isVisible ( ) . catch ( ( ) => false ) ) {
1299- await signIn . click ( )
1313+ await signIn . click ( { timeout : 15_000 } ) . catch ( ( ) => { } )
13001314 } else {
1301- await page . locator ( 'button:has-text("Sign in"), input[type="submit"][value="Sign in"]' ) . first ( ) . click ( )
1315+ await page
1316+ . locator ( 'button:has-text("Sign in"), input[type="submit"][value="Sign in"]' )
1317+ . first ( )
1318+ . click ( { timeout : 15_000 } )
13021319 }
13031320 // Same settle rule as loginAs(): leave /login, no networkidle (see comment there).
13041321 await expect ( page . locator ( 'a[href="/logout"]' ) ) . toBeVisible ( { timeout : 25_000 } )
@@ -1334,14 +1351,21 @@ async function isSoonVisible(locator: ReturnType<Page["locator"]>, timeoutMs = 2
13341351}
13351352
13361353async function failIfLoginPage ( page : Page , action : string ) : Promise < void > {
1337- if ( action . includes ( "Sign in" ) ) {
1354+ // The logged-out homepage IS the login page (heading "Sign in" plus a
1355+ // "Sign up" link). registration.feature's "I follow Sign up" is a real
1356+ // starting action there, not a lost session.
1357+ if ( / s i g n i n | s i g n u p | r e g i s t e r | f o r g o t | r e g i s t r a t i o n | p a s s w o r d / i. test ( action ) ) {
13381358 return
13391359 }
1360+ const lostSession = await page
1361+ . getByText ( / s e s s i o n d e t a i l s h a v e b e e n l o s t / i)
1362+ . isVisible ( )
1363+ . catch ( ( ) => false )
13401364 const onLogin = await page
13411365 . getByRole ( "heading" , { name : "Sign in" , exact : true } )
13421366 . isVisible ( )
13431367 . catch ( ( ) => false )
1344- if ( onLogin ) {
1368+ if ( lostSession || onLogin ) {
13451369 throw new Error ( `Cannot ${ action } : the session was lost and the login page is showing.` )
13461370 }
13471371}
@@ -1351,6 +1375,10 @@ async function dismissBlockingUi(page: Page): Promise<void> {
13511375 if ( ( await toastClose . count ( ) ) > 0 ) {
13521376 await toastClose . first ( ) . click ( { timeout : 1_000 } ) . catch ( ( ) => { } )
13531377 }
1378+ const cookieAccept = page . getByRole ( "button" , { name : / ^ ( A c c e p t | A c c e p t e r ) $ / i } )
1379+ if ( await cookieAccept . isVisible ( ) . catch ( ( ) => false ) ) {
1380+ await cookieAccept . click ( { timeout : 2_000 } ) . catch ( ( ) => { } )
1381+ }
13541382}
13551383
13561384async function clickFirstOrForce ( locator : ReturnType < Page [ "locator" ] > , page : Page ) : Promise < void > {
@@ -1957,7 +1985,7 @@ Then("I delete the document {string} if present", async ({ page }, rowText: stri
19571985// still cleans up a stray session left behind by an earlier partial/crashed
19581986// run, but no longer fails when there isn't one.
19591987Then ( "I delete the session {string} if present" , async ( { page } , sessionName : string ) => {
1960- const row = page . locator ( "tr" , { hasText : sessionName } )
1988+ const row = page . locator ( "tr" ) . filter ( { has : page . getByText ( sessionName , { exact : true } ) } )
19611989 // Bounded wait before the count() below decides "absent". /admin/session-list
19621990 // is a Vue page whose table body arrives from its own async data request,
19631991 // well after the "I wait for the page to be loaded" (domcontentloaded) step
@@ -1979,8 +2007,18 @@ Then("I delete the session {string} if present", async ({ page }, sessionName: s
19792007 return
19802008 }
19812009 page . once ( "dialog" , ( dialog ) => dialog . accept ( ) . catch ( ( ) => { } ) )
1982- await row . locator ( "button[title='Delete'], a[title='Delete'], .mdi-delete" ) . first ( ) . click ( )
1983- await pressButton ( page , "Yes" )
2010+ await row
2011+ . locator ( "button[title='Delete'], button[title='Supprimer'], a[title='Delete'], a[title='Supprimer'], .mdi-delete" )
2012+ . first ( )
2013+ . click ( )
2014+ const confirm = page . locator ( ".p-confirmdialog:visible, .p-dialog:visible" ) . last ( )
2015+ const confirmYes = confirm . getByRole ( "button" , { name : / ^ ( Y e s | O u i ) $ / i } )
2016+ if ( await isSoonVisible ( confirmYes , 5_000 ) ) {
2017+ await confirmYes . click ( )
2018+ } else {
2019+ await pressButton ( page , "Yes" )
2020+ }
2021+ await expect ( page . locator ( ".p-confirmdialog:visible" ) ) . toHaveCount ( 0 )
19842022 await expect ( page . locator ( "body" ) ) . not . toContainText ( sessionName )
19852023} )
19862024
@@ -2796,14 +2834,23 @@ When("I check every {string} option on the page", async ({ page }, label: string
27962834// it register" below for what that actually was.
27972835When ( "I press \"Next question\" until {string} appears" , async ( { page } , nextTitle : string ) => {
27982836 const heading = page . locator ( "h2" ) . filter ( { hasText : new RegExp ( `^\\s*${ escapeRegExp ( nextTitle ) } \\s*$` ) } )
2837+ const saving = page . getByRole ( "button" , { name : "Saving" , exact : true } )
27992838 const nextControl = page . locator ( "button:not([disabled]):not([aria-disabled='true'])" ) . filter ( {
28002839 hasText : / ^ \s * ( N e x t q u e s t i o n | N e x t p a g e ) \s * $ / ,
28012840 } )
2802- for ( let attempt = 0 ; attempt < 8 ; attempt ++ ) {
2803- if ( await isSoonVisible ( nextControl , 5_000 ) ) {
2841+ const deadline = Date . now ( ) + 50_000
2842+ while ( Date . now ( ) < deadline ) {
2843+ if ( await heading . first ( ) . isVisible ( ) . catch ( ( ) => false ) ) {
2844+ return
2845+ }
2846+ if ( await saving . isVisible ( ) . catch ( ( ) => false ) ) {
2847+ await saving . waitFor ( { state : "hidden" , timeout : 20_000 } ) . catch ( ( ) => { } )
2848+ continue
2849+ }
2850+ if ( await isSoonVisible ( nextControl , 3_000 ) ) {
28042851 await nextControl . first ( ) . click ( )
28052852 }
2806- if ( await isSoonVisible ( heading , 4_000 ) ) {
2853+ if ( await isSoonVisible ( heading , 3_000 ) ) {
28072854 return
28082855 }
28092856 }
@@ -2894,9 +2941,14 @@ When("I switch the LP resource panel to {string}", async ({ page }, resourceType
28942941When (
28952942 "I set the prerequisite of LP item {string} to {string} with minimum score {string}" ,
28962943 async ( { page } , targetItem : string , sourceItem : string , minimumScore : string ) => {
2944+ // specialCase1 switches the interface language to French mid-file.
2945+ // BaseButton only-icon uses the translated label as `title`/`aria-label`
2946+ // ("Pré-requis" / "Modifier les prérequis"), so an English-only title
2947+ // selector hung the full 15-minute budget with the French button visible.
28972948 await page
28982949 . locator ( ".rounded-lg.border.px-2.py-2" , { hasText : targetItem } )
2899- . locator ( '[title="Prerequisites"]' )
2950+ . locator ( '[title="Prerequisites"], [title="Pré-requis"], [aria-label="Prerequisites"], [aria-label="Pré-requis"]' )
2951+ . first ( )
29002952 . click ( )
29012953 const radio = page . getByRole ( "radio" , { name : sourceItem , exact : true } )
29022954 await radio . check ( )
@@ -2906,7 +2958,9 @@ When(
29062958 if ( await minimumInput . count ( ) ) {
29072959 await minimumInput . fill ( minimumScore )
29082960 }
2909- await page . getByRole ( "button" , { name : "Save prerequisites settings" } ) . click ( )
2961+ await page
2962+ . getByRole ( "button" , { name : / S a v e p r e r e q u i s i t e s s e t t i n g s | M o d i f i e r l e s p r é r e q u i s / i } )
2963+ . click ( )
29102964 } ,
29112965)
29122966
0 commit comments