test: cover core flows across browser engines
This commit is contained in:
parent
692f7c9baa
commit
5b723831af
|
|
@ -17,20 +17,46 @@ export const Hooks = {
|
|||
AidMap: {
|
||||
mounted() {
|
||||
this.markers = []
|
||||
this.markLoading = () => {
|
||||
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.markLoading = () => {
|
||||
this.el.dataset.mapReady = "false"
|
||||
}
|
||||
this.markReady = () => {
|
||||
this.el.dataset.mapReady = "true"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user