test: cover core flows across browser engines

This commit is contained in:
SimpleTest 2026-07-19 16:00:21 +03:00
parent 692f7c9baa
commit 5b723831af
16 changed files with 1142 additions and 976 deletions

View File

@ -17,20 +17,46 @@ export const Hooks = {
AidMap: { AidMap: {
mounted() { mounted() {
this.markers = [] this.markers = []
this.map = new maplibregl.Map({
container: this.el,
style: defaultStyle,
center: [30.5234, 50.4501],
zoom: 5
})
this.map.addControl(new maplibregl.NavigationControl(), "top-right")
this.markLoading = () => { this.markLoading = () => {
this.el.dataset.mapReady = "false" if (this.el.dataset.mapReady !== "true") {
this.el.dataset.mapReady = "false"
}
} }
this.markReady = () => { this.markReady = () => {
this.el.dataset.mapReady = "true" this.el.dataset.mapReady = "true"
} }
this.showUnavailable = () => {
this.map = null
this.el.dataset.mapUnavailable = "true"
this.markReady()
const fallback = document.createElement("p")
fallback.className = "grid h-full place-items-center p-6 text-center text-sm"
fallback.dataset.mapFallback = "true"
fallback.textContent = this.el.dataset.mapUnavailableLabel
this.el.replaceChildren(fallback)
}
if (!supportsMapCanvas()) {
this.showUnavailable()
return
}
try {
this.map = new maplibregl.Map({
container: this.el,
style: defaultStyle,
center: [30.5234, 50.4501],
zoom: 5
})
} catch (_error) {
this.showUnavailable()
return
}
this.map.addControl(new maplibregl.NavigationControl(), "top-right")
this.map.on("dataloading", this.markLoading) this.map.on("dataloading", this.markLoading)
this.map.on("load", this.markReady)
this.map.on("idle", this.markReady) this.map.on("idle", this.markReady)
this.renderMarkers() this.renderMarkers()
}, },
@ -39,6 +65,7 @@ export const Hooks = {
}, },
destroyed() { destroyed() {
this.map?.off("dataloading", this.markLoading) this.map?.off("dataloading", this.markLoading)
this.map?.off("load", this.markReady)
this.map?.off("idle", this.markReady) this.map?.off("idle", this.markReady)
this.map?.remove() this.map?.remove()
}, },
@ -129,3 +156,26 @@ function escapeHtml(value) {
node.textContent = value || "" node.textContent = value || ""
return node.innerHTML return node.innerHTML
} }
function supportsMapCanvas() {
const canvas = document.createElement("canvas")
const attributes = {
alpha: true,
depth: true,
stencil: true,
premultipliedAlpha: true
}
try {
const context =
canvas.getContext("webgl2", attributes) ||
canvas.getContext("webgl", attributes)
if (!context) return false
context.getExtension("WEBGL_lose_context")?.loseContext()
return true
} catch (_error) {
return false
}
}

View File

@ -24,7 +24,6 @@ export default defineConfig({
], ],
use: { use: {
baseURL, baseURL,
...devices["Desktop Chrome"],
actionTimeout: 10_000, actionTimeout: 10_000,
navigationTimeout: 20_000, navigationTimeout: 20_000,
trace: "retain-on-failure", trace: "retain-on-failure",
@ -35,7 +34,19 @@ export default defineConfig({
{ {
name: "chromium", name: "chromium",
use: { use: {
browserName: "chromium", ...devices["Desktop Chrome"],
},
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
},
},
{
name: "webkit",
use: {
...devices["Desktop Safari"],
}, },
}, },
], ],

View File

@ -2,6 +2,8 @@ import { expect, test } from "@playwright/test";
import { import {
captureBrowserFailures, captureBrowserFailures,
loginWithMagicLink, loginWithMagicLink,
projectEmail,
projectText,
registerAndConfirm, registerAndConfirm,
selectOptionContaining, selectOptionContaining,
waitForMapReady, waitForMapReady,
@ -10,17 +12,30 @@ import {
test("activity approval, privacy controls, reporting, and moderation work end to end", async ({ test("activity approval, privacy controls, reporting, and moderation work end to end", async ({
browser, browser,
request, request,
}) => { }, testInfo) => {
const projectName = testInfo.project.name;
const organizerEmail = projectEmail("activity-organizer", projectName);
const participantEmail = projectEmail("activity-participant", projectName);
const activityTitle = projectText("E2E public coffee meetup", projectName);
const organizerMessageText = projectText(
"Welcome to the E2E activity group.",
projectName,
);
const reportDescription = projectText(
"E2E moderation evidence for the selected activity message.",
projectName,
);
const proposalTitle = projectText("E2E quiet board games", projectName);
const organizer = await registerAndConfirm( const organizer = await registerAndConfirm(
browser, browser,
request, request,
"activity-organizer@example.invalid", organizerEmail,
"E2E Activity Organizer", "E2E Activity Organizer",
); );
const participant = await registerAndConfirm( const participant = await registerAndConfirm(
browser, browser,
request, request,
"activity-participant@example.invalid", participantEmail,
"E2E Activity Participant", "E2E Activity Participant",
); );
const assertOrganizerClean = captureBrowserFailures(organizer.page); const assertOrganizerClean = captureBrowserFailures(organizer.page);
@ -33,7 +48,7 @@ test("activity approval, privacy controls, reporting, and moderation work end to
await organizer.page.goto("/activities/new"); await organizer.page.goto("/activities/new");
await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea"); await selectOptionContaining(organizer.page, "Activity type", "Coffee or tea");
await organizer.page.getByLabel("Preferred setting").selectOption("cafe"); await organizer.page.getByLabel("Preferred setting").selectOption("cafe");
await organizer.page.getByLabel("Short title").fill("E2E public coffee meetup"); await organizer.page.getByLabel("Short title").fill(activityTitle);
await organizer.page await organizer.page
.getByLabel("Plan and expectations") .getByLabel("Plan and expectations")
.fill("Meet at a public café for an hour of safe conversation."); .fill("Meet at a public café for an hour of safe conversation.");
@ -80,13 +95,13 @@ test("activity approval, privacy controls, reporting, and moderation work end to
await organizer.page await organizer.page
.getByPlaceholder("Message the approved group") .getByPlaceholder("Message the approved group")
.fill("Welcome to the E2E activity group."); .fill(organizerMessageText);
await organizer.page.getByRole("button", { name: "Send", exact: true }).click(); await organizer.page.getByRole("button", { name: "Send", exact: true }).click();
await expect(participant.page.getByText("Welcome to the E2E activity group.")).toBeVisible(); await expect(participant.page.getByText(organizerMessageText)).toBeVisible();
const organizerMessage = participant.page const organizerMessage = participant.page
.locator("#activity-messages article") .locator("#activity-messages article")
.filter({ hasText: "Welcome to the E2E activity group." }); .filter({ hasText: organizerMessageText });
await organizerMessage.getByRole("button", { name: "Report" }).click(); await organizerMessage.getByRole("button", { name: "Report" }).click();
const safetyControls = participant.page const safetyControls = participant.page
.getByRole("heading", { name: "Safety controls" }) .getByRole("heading", { name: "Safety controls" })
@ -97,13 +112,13 @@ test("activity approval, privacy controls, reporting, and moderation work end to
await safetyControls.getByLabel("Reason").selectOption("harassment"); await safetyControls.getByLabel("Reason").selectOption("harassment");
await safetyControls await safetyControls
.getByLabel("What happened?") .getByLabel("What happened?")
.fill("E2E moderation evidence for the selected activity message."); .fill(reportDescription);
await safetyControls.getByRole("button", { name: "Send report" }).click(); await safetyControls.getByRole("button", { name: "Send report" }).click();
await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible(); await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible();
await waitForMapReady(participant.page); await waitForMapReady(participant.page);
await participant.page.goto("/categories/proposals"); await participant.page.goto("/categories/proposals");
await participant.page.getByLabel("Proposed category").fill("E2E quiet board games"); await participant.page.getByLabel("Proposed category").fill(proposalTitle);
await participant.page.getByLabel("Mode").selectOption("activity"); await participant.page.getByLabel("Mode").selectOption("activity");
await participant.page await participant.page
.getByLabel("Why is this useful?") .getByLabel("Why is this useful?")
@ -157,12 +172,12 @@ test("activity approval, privacy controls, reporting, and moderation work end to
const reportCard = admin.page const reportCard = admin.page
.locator("article") .locator("article")
.filter({ hasText: "E2E moderation evidence for the selected activity message." }); .filter({ hasText: reportDescription });
await reportCard.getByRole("button", { name: "View scoped evidence" }).click(); await reportCard.getByRole("button", { name: "View scoped evidence" }).click();
const evidence = admin.page const evidence = admin.page
.getByRole("heading", { name: "Reported evidence" }) .getByRole("heading", { name: "Reported evidence" })
.locator("xpath=ancestor::section"); .locator("xpath=ancestor::section");
await expect(evidence.getByText("Welcome to the E2E activity group.")).toBeVisible(); await expect(evidence.getByText(organizerMessageText)).toBeVisible();
await evidence.getByRole("button", { name: "Close" }).click(); await evidence.getByRole("button", { name: "Close" }).click();
await reportCard.getByRole("combobox").selectOption("resolved"); await reportCard.getByRole("combobox").selectOption("resolved");
await reportCard.getByPlaceholder("Resolution note").fill("Reviewed in isolated browser E2E."); await reportCard.getByPlaceholder("Resolution note").fill("Reviewed in isolated browser E2E.");
@ -171,14 +186,14 @@ test("activity approval, privacy controls, reporting, and moderation work end to
const proposalCard = admin.page const proposalCard = admin.page
.locator("article") .locator("article")
.filter({ hasText: "E2E quiet board games" }); .filter({ hasText: proposalTitle });
await proposalCard.getByPlaceholder("Rejection note").fill("Covered by an existing category."); await proposalCard.getByPlaceholder("Rejection note").fill("Covered by an existing category.");
await proposalCard.getByRole("button", { name: "Reject" }).click(); await proposalCard.getByRole("button", { name: "Reject" }).click();
await expect(admin.page.getByText("Proposal rejected.")).toBeVisible(); await expect(admin.page.getByText("Proposal rejected.")).toBeVisible();
const participantRow = admin.page const participantRow = admin.page
.locator("tbody tr") .locator("tbody tr")
.filter({ hasText: "activity-participant@example.invalid" }); .filter({ hasText: participantEmail });
await participantRow.getByRole("combobox").first().selectOption("restricted"); await participantRow.getByRole("combobox").first().selectOption("restricted");
await participantRow.getByPlaceholder("Internal note").fill("E2E restriction boundary."); await participantRow.getByPlaceholder("Internal note").fill("E2E restriction boundary.");
await participantRow.getByRole("button", { name: "Save" }).click(); await participantRow.getByRole("button", { name: "Save" }).click();

View File

@ -19,6 +19,15 @@ export type AuthenticatedBrowser = {
email: string; email: string;
}; };
export function projectEmail(localPart: string, projectName: string): string {
const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
return `${localPart}+${scope}@example.invalid`;
}
export function projectText(value: string, projectName: string): string {
return `${value} [${projectName}]`;
}
export async function newIsolatedContext(browser: Browser): Promise<BrowserContext> { export async function newIsolatedContext(browser: Browser): Promise<BrowserContext> {
return browser.newContext({ serviceWorkers: "block" }); return browser.newContext({ serviceWorkers: "block" });
} }
@ -27,7 +36,22 @@ export async function waitForMapReady(page: Page): Promise<void> {
const map = page.locator(".aid-map"); const map = page.locator(".aid-map");
if ((await map.count()) > 0) { if ((await map.count()) > 0) {
await expect(map).toHaveAttribute("data-map-ready", "true", { timeout: 15_000 }); await expect
.poll(
async () => {
if ((await map.getAttribute("data-map-ready")) === "true") {
return "map";
}
if ((await map.locator("[data-map-fallback=true]").count()) === 1) {
return "fallback";
}
return null;
},
{ timeout: 15_000 },
)
.not.toBeNull();
} }
} }
@ -45,8 +69,19 @@ export function captureBrowserFailures(page: Page): () => void {
}); });
page.on("requestfailed", (request) => { page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "";
if (
request.url().includes("/__e2e__/map-tile.png") &&
(failure.includes("Load request cancelled") ||
failure.includes("ERR_ABORTED") ||
failure.includes("NS_BINDING_ABORTED"))
) {
return;
}
failures.push( failures.push(
`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText}`, `requestfailed: ${request.method()} ${request.url()} ${failure}`,
); );
}); });

View File

@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { newIsolatedContext, registerAndConfirm } from "./helpers"; import { newIsolatedContext, projectEmail, registerAndConfirm } from "./helpers";
test("public language selection persists across pages", async ({ browser }) => { test("public language selection persists across pages", async ({ browser }) => {
const context = await newIsolatedContext(browser); const context = await newIsolatedContext(browser);
@ -19,11 +19,14 @@ test("public language selection persists across pages", async ({ browser }) => {
await context.close(); await context.close();
}); });
test("authenticated LiveView uses the selected browser language", async ({ browser, request }) => { test("authenticated LiveView uses the selected browser language", async ({
browser,
request,
}, testInfo) => {
const user = await registerAndConfirm( const user = await registerAndConfirm(
browser, browser,
request, request,
"localization@example.invalid", projectEmail("localization", testInfo.project.name),
"E2E Localization", "E2E Localization",
); );

View File

@ -1,6 +1,8 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { import {
captureBrowserFailures, captureBrowserFailures,
projectEmail,
projectText,
registerAndConfirm, registerAndConfirm,
selectOptionContaining, selectOptionContaining,
waitForMapReady, waitForMapReady,
@ -9,17 +11,18 @@ import {
test("two users complete a medicine handover with realtime chat and blind reviews", async ({ test("two users complete a medicine handover with realtime chat and blind reviews", async ({
browser, browser,
request, request,
}) => { }, testInfo) => {
const requestTitle = projectText("E2E medicine pickup", testInfo.project.name);
const requester = await registerAndConfirm( const requester = await registerAndConfirm(
browser, browser,
request, request,
"requester@example.invalid", projectEmail("requester", testInfo.project.name),
"E2E Requester", "E2E Requester",
); );
const helper = await registerAndConfirm( const helper = await registerAndConfirm(
browser, browser,
request, request,
"helper@example.invalid", projectEmail("helper", testInfo.project.name),
"E2E Helper", "E2E Helper",
); );
const assertRequesterClean = captureBrowserFailures(requester.page); const assertRequesterClean = captureBrowserFailures(requester.page);
@ -28,7 +31,7 @@ test("two users complete a medicine handover with realtime chat and blind review
await requester.page.goto("/requests/new"); await requester.page.goto("/requests/new");
await selectOptionContaining(requester.page, "Category", "Medicine pickup"); await selectOptionContaining(requester.page, "Category", "Medicine pickup");
await requester.page.getByLabel("Medicine pickup status").selectOption("reserved"); await requester.page.getByLabel("Medicine pickup status").selectOption("reserved");
await requester.page.getByLabel("Short title").fill("E2E medicine pickup"); await requester.page.getByLabel("Short title").fill(requestTitle);
await requester.page await requester.page
.getByLabel("What help do you need?") .getByLabel("What help do you need?")
.fill("Please collect the legal medicine that is already reserved."); .fill("Please collect the legal medicine that is already reserved.");
@ -45,7 +48,7 @@ test("two users complete a medicine handover with realtime chat and blind review
await requester.page.getByRole("button", { name: "Publish request" }).click(); await requester.page.getByRole("button", { name: "Publish request" }).click();
await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/); await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);
const requestURL = requester.page.url(); const requestURL = requester.page.url();
await expect(requester.page.getByRole("heading", { name: "E2E medicine pickup" })).toBeVisible(); await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible();
await helper.page.goto(requestURL); await helper.page.goto(requestURL);
await helper.page.getByRole("button", { name: "I can help" }).click(); await helper.page.getByRole("button", { name: "I can help" }).click();

View File

@ -1,14 +1,14 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { registerAndConfirm } from "./helpers"; import { projectEmail, registerAndConfirm } from "./helpers";
test("a disconnected LiveView announces recovery and clears it after reconnect", async ({ test("a disconnected LiveView announces recovery and clears it after reconnect", async ({
browser, browser,
request, request,
}) => { }, testInfo) => {
const user = await registerAndConfirm( const user = await registerAndConfirm(
browser, browser,
request, request,
"resilience@example.invalid", projectEmail("resilience", testInfo.project.name),
"E2E Resilience", "E2E Resilience",
); );

View File

@ -25,6 +25,7 @@ defmodule WhoNeedHelpWeb.Endpoint do
from: :who_need_help, from: :who_need_help,
gzip: not code_reloading?, gzip: not code_reloading?,
only: WhoNeedHelpWeb.static_paths(), only: WhoNeedHelpWeb.static_paths(),
only_matching: ~w(favicon manifest robots sw),
raise_on_missing_only: code_reloading? raise_on_missing_only: code_reloading?
# Code reloading can be explicitly enabled under the # Code reloading can be explicitly enabled under the

View File

@ -209,6 +209,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
phx-hook="AidMap" phx-hook="AidMap"
phx-update="ignore" phx-update="ignore"
data-markers={@markers} data-markers={@markers}
data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.")
}
class="aid-map" class="aid-map"
/> />
</aside> </aside>

View File

@ -475,6 +475,9 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
phx-hook="AidMap" phx-hook="AidMap"
phx-update="ignore" phx-update="ignore"
data-markers={@markers} data-markers={@markers}
data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.")
}
class="aid-map" class="aid-map"
/> />
<div class="rounded-2xl bg-base-200 p-5 text-sm"> <div class="rounded-2xl bg-base-200 p-5 text-sm">

View File

@ -234,6 +234,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
phx-hook="AidMap" phx-hook="AidMap"
phx-update="ignore" phx-update="ignore"
data-markers={@markers} data-markers={@markers}
data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.")
}
class="aid-map" class="aid-map"
/> />
<div class="rounded-3xl bg-base-200 p-5"> <div class="rounded-3xl bg-base-200 p-5">

View File

@ -734,6 +734,9 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
phx-hook="AidMap" phx-hook="AidMap"
phx-update="ignore" phx-update="ignore"
data-markers={@markers} data-markers={@markers}
data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.")
}
class="aid-map" class="aid-map"
/> />

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff