diff --git a/assets/css/app.css b/assets/css/app.css index 083bf26..0372f4b 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -753,7 +753,7 @@ html { } .category-picker-placeholder { - color: color-mix(in oklab, var(--color-base-content) 48%, transparent); + color: color-mix(in oklab, var(--color-base-content) 70%, var(--color-base-100)); } .category-picker-panel { diff --git a/assets/js/hooks.js b/assets/js/hooks.js index 5f18335..6aeead8 100644 --- a/assets/js/hooks.js +++ b/assets/js/hooks.js @@ -2115,6 +2115,7 @@ export const Hooks = { this.mountMap = () => { if (!this.mapElement) return + this.mapElement.dataset.mapIdle = "false" const support = mapCanvasSupport() if (!support.supported) { @@ -2149,6 +2150,12 @@ export const Hooks = { this.map.touchZoomRotate.disableRotation() this.map.addControl(new maplibregl.NavigationControl({showCompass: false}), "top-left") + this.map.on("dataloading", () => { + if (this.mapElement) this.mapElement.dataset.mapIdle = "false" + }) + this.map.on("idle", () => { + if (this.mapElement) this.mapElement.dataset.mapIdle = "true" + }) this.map.on("load", () => { this.mapLoaded = true diff --git a/e2e/tests/accessibility-resilience.spec.ts b/e2e/tests/accessibility-resilience.spec.ts index 98c068c..7d68b6a 100644 --- a/e2e/tests/accessibility-resilience.spec.ts +++ b/e2e/tests/accessibility-resilience.spec.ts @@ -91,7 +91,9 @@ test("public navigation does not overflow narrow or wide viewports", async ({ br await expect(page.getByRole("link", { name: "Activities", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Categories", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Helpers", exact: true })).toBeVisible(); - await expect(page.getByRole("link", { name: "English", exact: true })).toBeVisible(); + await expect( + page.locator("summary[aria-label='Language: English']:visible"), + ).toBeVisible(); await expect(page.getByRole("link", { name: "Log in", exact: true })).toBeVisible(); const openDimensions = await page.evaluate(() => ({ @@ -103,7 +105,9 @@ test("public navigation does not overflow narrow or wide viewports", async ({ br } else { await expect(page.locator("summary[aria-label='Primary navigation']")).toBeHidden(); await expect(page.getByRole("link", { name: "Requests", exact: true })).toBeVisible(); - await expect(page.getByRole("link", { name: "English", exact: true })).toBeVisible(); + await expect( + page.locator("summary[aria-label='Language: English']:visible"), + ).toBeVisible(); await expect(page.getByRole("link", { name: "Register", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Log in", exact: true })).toBeVisible(); } diff --git a/e2e/tests/activity-moderation.spec.ts b/e2e/tests/activity-moderation.spec.ts index 6e75f01..e1e37d4 100644 --- a/e2e/tests/activity-moderation.spec.ts +++ b/e2e/tests/activity-moderation.spec.ts @@ -157,8 +157,22 @@ test("activity approval, privacy controls, reporting, and moderation work end to await waitForMapReady(participant.page); await gotoLiveView(participant.page, "/categories/proposals"); - await participant.page.getByLabel("Proposed category").fill(proposalTitle); - await participant.page.getByLabel("Mode").selectOption("activity"); + await participant.page + .getByLabel("Where should it appear?") + .selectOption("activity"); + await participant.page + .locator("label.category-proposal-kind") + .filter({ hasText: "New subcategory" }) + .click(); + await expect( + participant.page.getByRole("radio", { name: /New subcategory/ }), + ).toBeChecked(); + await selectOptionContaining( + participant.page, + "Main category", + "Social activity", + ); + await participant.page.getByLabel("Subcategory name").fill(proposalTitle); await participant.page .getByLabel("Why is this useful?") .fill("A moderated public activity category for quiet board-game meetups."); @@ -247,8 +261,17 @@ test("activity approval, privacy controls, reporting, and moderation work end to await expect(admin.page.getByText("User status updated.")).toBeVisible(); await gotoLiveView(participant.page, "/categories/proposals"); - await participant.page.getByLabel("Proposed category").fill("E2E restricted proposal"); - await participant.page.getByLabel("Mode").selectOption("help"); + await participant.page + .getByLabel("Where should it appear?") + .selectOption("help"); + await participant.page + .locator("label.category-proposal-kind") + .filter({ hasText: "New main category" }) + .click(); + await expect( + participant.page.getByRole("radio", { name: /New main category/ }), + ).toBeChecked(); + await participant.page.getByLabel("Category name").fill("E2E restricted proposal"); await participant.page .getByLabel("Why is this useful?") .fill("This must be rejected because the account is restricted."); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 98007d2..0b5ddbf 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -454,9 +454,14 @@ export async function loginWithPassword( const form = page.locator("#login_form_password"); await page.getByText("Use a password instead", { exact: true }).click(); await expect(form).toBeVisible(); - await form.getByLabel("Email").fill(email); - await form.getByLabel("Password").fill(password); - await expect(form.getByLabel("Password")).toHaveValue(password); + const emailInput = form.getByLabel("Email"); + await emailInput.click(); + await emailInput.pressSequentially(email); + await expect(emailInput).toHaveValue(email); + const passwordInput = form.getByLabel("Password"); + await passwordInput.click(); + await passwordInput.pressSequentially(password); + await expect(passwordInput).toHaveValue(password); await form.getByRole("button", { name: "Log in only this time" }).click(); await expect(page).not.toHaveURL(/\/users\/log-in$/, { timeout: 10_000 }); await expect(page.getByText(email, { exact: true })).toBeVisible({ @@ -493,18 +498,27 @@ export async function selectOptionContaining( expectedText: string, ): Promise { await waitForLiveViewConnected(page); - const select = page.getByLabel(label); - const options = await select.locator("option").evaluateAll((nodes) => - nodes.map((node) => ({ - text: node.textContent?.trim() ?? "", - value: (node as HTMLOptionElement).value, - })), - ); - const option = options.find(({ text }) => text.includes(expectedText)); + const picker = page + .locator(".category-picker") + .filter({ + has: page.locator(":scope > .label", { hasText: label }), + }) + .first(); + const source = picker.locator("[data-category-picker-source]"); + const option = picker + .locator("[data-category-picker-option]") + .filter({ hasText: expectedText }) + .first(); - if (!option) { - throw new Error(`No ${label} option contains ${expectedText}`); + await expect(picker, `No category picker labelled ${label}`).toBeVisible(); + await picker.locator("[data-category-picker-trigger]").click(); + await expect(option, `No ${label} option contains ${expectedText}`).toBeVisible(); + const value = await option.getAttribute("data-value"); + + if (!value) { + throw new Error(`${label} option ${expectedText} has no value`); } - await select.selectOption(option.value); + await option.click(); + await expect(source).toHaveValue(value); } diff --git a/e2e/tests/notifications-data.spec.ts b/e2e/tests/notifications-data.spec.ts index 71a919b..5bc1de4 100644 --- a/e2e/tests/notifications-data.spec.ts +++ b/e2e/tests/notifications-data.spec.ts @@ -59,6 +59,24 @@ test("nearby alerts, private notification inbox, preferences, and data export wo await subscriptionForm .locator('input[name="nearby_subscription[radius_meters]"][value="3000"]') .check(); + await expect + .poll( + async () => { + const map = subscriber.page.locator("#nearby-subscription-map"); + + if ((await map.getAttribute("data-map-idle")) === "true") { + return "idle"; + } + + if ((await map.locator("[data-map-fallback=true]").count()) === 1) { + return "fallback"; + } + + return null; + }, + { timeout: 15_000 }, + ) + .not.toBeNull(); await subscriptionForm.getByLabel("Email notification").check(); await subscriptionForm.getByRole("button", { name: "Create nearby alert" }).click(); diff --git a/scripts/upgrade-rehearsal-compose.sh b/scripts/upgrade-rehearsal-compose.sh index 5009295..6c41e22 100755 --- a/scripts/upgrade-rehearsal-compose.sh +++ b/scripts/upgrade-rehearsal-compose.sh @@ -148,6 +148,10 @@ cleanup() { if [ "$status" -ne 0 ]; then compose ps --all >"$output_dir/compose-ps.txt" 2>&1 || true + compose logs --no-color proxy \ + >"$output_dir/proxy.log" 2>&1 || true + compose logs --no-color docker-api-proxy \ + >"$output_dir/docker-api-proxy.log" 2>&1 || true fi if ! compose down --volumes --remove-orphans >/dev/null 2>&1; then @@ -349,6 +353,39 @@ if [ -z "$web_container" ]; then exit 1 fi +# Docker Compose considers a running proxy without a container healthcheck +# ready before Traefik necessarily applies its first Docker-provider snapshot. +# Wait for the application route itself so the assertions below measure the +# configured router rather than that short provider-initialisation window. +route_wait_attempts=30 +route_wait_attempt=1 +route_status=000 + +while [ "$route_wait_attempt" -le "$route_wait_attempts" ]; do + route_status=$( + curl \ + --silent \ + --show-error \ + --output /dev/null \ + --write-out '%{http_code}' \ + --header "Host: $PHX_HOST" \ + "http://127.0.0.1:$proxy_port/healthz/ready" || + printf '000' + ) + + if [ "$route_status" = "200" ]; then + break + fi + + sleep 1 + route_wait_attempt=$((route_wait_attempt + 1)) +done + +if [ "$route_status" != "200" ]; then + echo "The isolated proxy route did not become ready after $route_wait_attempts checks; last HTTP status: $route_status." >&2 + exit 1 +fi + case "$PHX_SCHEME:$PHX_URL_PORT" in http:80 | https:443) public_origin="$PHX_SCHEME://$PHX_HOST"