Skip to content

Commit 1f3830a

Browse files
committed
CI: Fix test suite
1 parent 747e2b5 commit 1f3830a

7 files changed

Lines changed: 102 additions & 33 deletions

File tree

src/CoreBundle/State/Exercise/ExerciseRuntimeAnswerProcessor.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
120120

121121
$course = $this->cidReqHelper->requireDoctrineCourseEntity();
122122
$session = $this->cidReqHelper->getDoctrineSessionEntity();
123+
// Draft-save is called on every Next click while the player also
124+
// polls. Holding the session lock here leaves the Vue Next button
125+
// stuck on "Saving" for the rest of the test timeout.
126+
if (\function_exists('session_write_close')) {
127+
session_write_close();
128+
}
123129
$exerciseId = isset($uriVariables['exerciseId']) ? (int) $uriVariables['exerciseId'] : (int) ($data->exerciseId ?? 0);
124130
$attemptId = isset($uriVariables['attemptId']) ? (int) $uriVariables['attemptId'] : (int) ($data->attemptId ?? 0);
125131
$questionId = (int) ($data->questionId ?? 0);

src/CoreBundle/State/Exercise/ExerciseRuntimeFinishProcessor.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
161161

162162
$course = $this->cidReqHelper->requireDoctrineCourseEntity();
163163
$session = $this->cidReqHelper->getDoctrineSessionEntity();
164+
if (\function_exists('session_write_close')) {
165+
session_write_close();
166+
}
164167
$exerciseId = isset($uriVariables['exerciseId']) ? (int) $uriVariables['exerciseId'] : (int) ($data->exerciseId ?? 0);
165168
$attemptId = isset($uriVariables['attemptId']) ? (int) $uriVariables['attemptId'] : (int) ($data->attemptId ?? 0);
166169

tests/playwright/.features-gen/features/toolExerciseAdmin.feature.spec.js

Lines changed: 7 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/playwright/.features-gen/features/toolExerciseTeacher.feature.spec.js

Lines changed: 8 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/playwright/features/toolExerciseAdmin.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ Feature: Exercise tool
394394
And I wait for the page content to settle
395395
Then I should see "Definition of oligarchy"
396396

397+
@slow-scenario
397398
Scenario: Try exercise "Exercise 1"
398399
# TEMP has no students on a fresh install. course_user_registration.feature
399400
# also subscribes acostea, but that file runs in a different worker with

tests/playwright/features/toolExerciseTeacher.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,7 @@ Feature: Exercise tool
595595
And I wait for the page to be loaded
596596
Then I should see "subscribed to the course"
597597

598+
@slow-scenario
598599
Scenario: Try exercise "Exercise 1"
599600
Given I am a student
600601
And I wait for the page to be loaded

tests/playwright/steps/common.steps.ts

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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) {
12921306
async 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

13361353
async 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 (/sign in|sign up|register|forgot|registration|password/i.test(action)) {
13381358
return
13391359
}
1360+
const lostSession = await page
1361+
.getByText(/session details have been lost/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: /^(Accept|Accepter)$/i })
1379+
if (await cookieAccept.isVisible().catch(() => false)) {
1380+
await cookieAccept.click({ timeout: 2_000 }).catch(() => {})
1381+
}
13541382
}
13551383

13561384
async 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.
19591987
Then("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: /^(Yes|Oui)$/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.
27972835
When("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*(Next question|Next page)\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
28942941
When(
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: /Save prerequisites settings|Modifier les prérequis/i })
2963+
.click()
29102964
},
29112965
)
29122966

0 commit comments

Comments
 (0)