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: {
mounted() {
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.el.dataset.mapReady = "false"
if (this.el.dataset.mapReady !== "true") {
this.el.dataset.mapReady = "false"
}
}
this.markReady = () => {
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("load", this.markReady)
this.map.on("idle", this.markReady)
this.renderMarkers()
},
@ -39,6 +65,7 @@ export const Hooks = {
},
destroyed() {
this.map?.off("dataloading", this.markLoading)
this.map?.off("load", this.markReady)
this.map?.off("idle", this.markReady)
this.map?.remove()
},
@ -129,3 +156,26 @@ function escapeHtml(value) {
node.textContent = value || ""
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: {
baseURL,
...devices["Desktop Chrome"],
actionTimeout: 10_000,
navigationTimeout: 20_000,
trace: "retain-on-failure",
@ -35,7 +34,19 @@ export default defineConfig({
{
name: "chromium",
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 {
captureBrowserFailures,
loginWithMagicLink,
projectEmail,
projectText,
registerAndConfirm,
selectOptionContaining,
waitForMapReady,
@ -10,17 +12,30 @@ import {
test("activity approval, privacy controls, reporting, and moderation work end to end", async ({
browser,
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(
browser,
request,
"activity-organizer@example.invalid",
organizerEmail,
"E2E Activity Organizer",
);
const participant = await registerAndConfirm(
browser,
request,
"activity-participant@example.invalid",
participantEmail,
"E2E Activity Participant",
);
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 selectOptionContaining(organizer.page, "Activity type", "Coffee or tea");
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
.getByLabel("Plan and expectations")
.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
.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 expect(participant.page.getByText("Welcome to the E2E activity group.")).toBeVisible();
await expect(participant.page.getByText(organizerMessageText)).toBeVisible();
const organizerMessage = participant.page
.locator("#activity-messages article")
.filter({ hasText: "Welcome to the E2E activity group." });
.filter({ hasText: organizerMessageText });
await organizerMessage.getByRole("button", { name: "Report" }).click();
const safetyControls = participant.page
.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("What happened?")
.fill("E2E moderation evidence for the selected activity message.");
.fill(reportDescription);
await safetyControls.getByRole("button", { name: "Send report" }).click();
await expect(participant.page.getByText("Report sent to moderators.")).toBeVisible();
await waitForMapReady(participant.page);
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("Why is this useful?")
@ -157,12 +172,12 @@ test("activity approval, privacy controls, reporting, and moderation work end to
const reportCard = admin.page
.locator("article")
.filter({ hasText: "E2E moderation evidence for the selected activity message." });
.filter({ hasText: reportDescription });
await reportCard.getByRole("button", { name: "View scoped evidence" }).click();
const evidence = admin.page
.getByRole("heading", { name: "Reported evidence" })
.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 reportCard.getByRole("combobox").selectOption("resolved");
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
.locator("article")
.filter({ hasText: "E2E quiet board games" });
.filter({ hasText: proposalTitle });
await proposalCard.getByPlaceholder("Rejection note").fill("Covered by an existing category.");
await proposalCard.getByRole("button", { name: "Reject" }).click();
await expect(admin.page.getByText("Proposal rejected.")).toBeVisible();
const participantRow = admin.page
.locator("tbody tr")
.filter({ hasText: "activity-participant@example.invalid" });
.filter({ hasText: participantEmail });
await participantRow.getByRole("combobox").first().selectOption("restricted");
await participantRow.getByPlaceholder("Internal note").fill("E2E restriction boundary.");
await participantRow.getByRole("button", { name: "Save" }).click();

View File

@ -19,6 +19,15 @@ export type AuthenticatedBrowser = {
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> {
return browser.newContext({ serviceWorkers: "block" });
}
@ -27,7 +36,22 @@ export async function waitForMapReady(page: Page): Promise<void> {
const map = page.locator(".aid-map");
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) => {
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(
`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 { newIsolatedContext, registerAndConfirm } from "./helpers";
import { newIsolatedContext, projectEmail, registerAndConfirm } from "./helpers";
test("public language selection persists across pages", async ({ browser }) => {
const context = await newIsolatedContext(browser);
@ -19,11 +19,14 @@ test("public language selection persists across pages", async ({ browser }) => {
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(
browser,
request,
"localization@example.invalid",
projectEmail("localization", testInfo.project.name),
"E2E Localization",
);

View File

@ -1,6 +1,8 @@
import { expect, test } from "@playwright/test";
import {
captureBrowserFailures,
projectEmail,
projectText,
registerAndConfirm,
selectOptionContaining,
waitForMapReady,
@ -9,17 +11,18 @@ import {
test("two users complete a medicine handover with realtime chat and blind reviews", async ({
browser,
request,
}) => {
}, testInfo) => {
const requestTitle = projectText("E2E medicine pickup", testInfo.project.name);
const requester = await registerAndConfirm(
browser,
request,
"requester@example.invalid",
projectEmail("requester", testInfo.project.name),
"E2E Requester",
);
const helper = await registerAndConfirm(
browser,
request,
"helper@example.invalid",
projectEmail("helper", testInfo.project.name),
"E2E Helper",
);
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 selectOptionContaining(requester.page, "Category", "Medicine pickup");
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
.getByLabel("What help do you need?")
.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 expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);
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.getByRole("button", { name: "I can help" }).click();

View File

@ -1,14 +1,14 @@
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 ({
browser,
request,
}) => {
}, testInfo) => {
const user = await registerAndConfirm(
browser,
request,
"resilience@example.invalid",
projectEmail("resilience", testInfo.project.name),
"E2E Resilience",
);

View File

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

View File

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

View File

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

View File

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