Fix map recovery and clean transient test resources

This commit is contained in:
SimpleTest 2026-07-22 19:45:50 +03:00
parent 1db67b1048
commit e2bc8acf97
27 changed files with 1918 additions and 1109 deletions

View File

@ -14,14 +14,15 @@ TRAEFIK_ROUTER_RULE='PathPrefix(`/`)'
PHX_HOST=proxy PHX_HOST=proxy
PHX_SCHEME=https PHX_SCHEME=https
PHX_URL_PORT=443 PHX_URL_PORT=443
MAP_TILE_URL=https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y} MAP_TILE_URL=/__e2e__/map-tile.png?z={z}&x={x}&y={y}
DEPLOYMENT_TARGET=compose DEPLOYMENT_TARGET=compose
DEPLOYMENT_ENV=development DEPLOYMENT_ENV=development
COMPOSE_PROJECT_NAME=who_need_help_e2e COMPOSE_PROJECT_NAME=who_need_help_e2e
APP_IMAGE=who-need-help:e2e APP_IMAGE=who-need-help:e2e-GENERATED_UNIQUE_E2E_RUN
SOCKET_PROXY_IMAGE=who-need-help:socket-proxy-e2e SOCKET_PROXY_IMAGE=who-need-help:socket-proxy-e2e-GENERATED_UNIQUE_E2E_RUN
POSTGIS_IMAGE=who-need-help:postgis-e2e POSTGIS_IMAGE=who-need-help:postgis-e2e-GENERATED_UNIQUE_E2E_RUN
E2E_TEST_IMAGE=who-need-help-e2e-tests:GENERATED_UNIQUE_E2E_RUN
APP_TOPOLOGY=split APP_TOPOLOGY=split
DATABASE_MODE=container DATABASE_MODE=container

View File

@ -358,6 +358,29 @@ html {
display: none; display: none;
} }
.map-unavailable-panel {
display: grid;
min-height: inherit;
place-content: center;
justify-items: center;
gap: 0.875rem;
padding: 2rem;
text-align: center;
}
.map-unavailable-panel p {
max-width: 34rem;
color: color-mix(in oklab, var(--color-base-content) 68%, transparent);
font-size: 0.875rem;
line-height: 1.5;
}
.map-retry-button {
position: relative;
z-index: 1;
background: color-mix(in oklab, var(--color-base-100) 94%, transparent);
}
.home-demo-map-fallback { .home-demo-map-fallback {
position: relative; position: relative;
display: grid; display: grid;
@ -406,6 +429,11 @@ html {
gap: 0.625rem; gap: 0.625rem;
} }
.home-demo-map-fallback > .map-retry-button {
justify-self: start;
margin-top: 0.75rem;
}
.home-demo-map-fallback-point { .home-demo-map-fallback-point {
display: flex; display: flex;
align-items: center; align-items: center;
@ -607,8 +635,7 @@ html {
color: color-mix(in oklab, var(--color-base-content) 65%, transparent); color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
} }
.request-location-map-shell:has(.request-location-map[data-map-unavailable="true"]) .request-location-workspace[data-map-unavailable="true"] .request-location-map-hint {
.request-location-map-hint {
display: none; display: none;
} }

View File

@ -137,6 +137,27 @@ const privacySafeAreaCenter = (latitude, longitude, radiusMeters) => {
} }
} }
const mapRetryButton = (element, retry) => {
const button = document.createElement("button")
button.type = "button"
button.className = "btn btn-sm btn-outline map-retry-button"
button.dataset.mapRetry = "true"
button.textContent = element.dataset.mapRetryLabel || "Try map again"
button.addEventListener("click", retry, {once: true})
return button
}
const reportMapUnavailable = (element, reason, error = null) => {
element.dataset.mapUnavailable = "true"
element.dataset.mapUnavailableReason = reason
if (error) {
console.warn(`[Who Need Help] Map unavailable (${reason})`, error)
} else {
console.warn(`[Who Need Help] Map unavailable (${reason})`)
}
}
const createAidMap = element => { const createAidMap = element => {
const state = { const state = {
element, element,
@ -154,7 +175,8 @@ const createAidMap = element => {
areaSelectedId: `${element.id || "aid-map"}-area-selected`, areaSelectedId: `${element.id || "aid-map"}-area-selected`,
clusterSourceId: `${element.id || "aid-map"}-clusters`, clusterSourceId: `${element.id || "aid-map"}-clusters`,
clusterCircleId: `${element.id || "aid-map"}-cluster-circle`, clusterCircleId: `${element.id || "aid-map"}-cluster-circle`,
clusterCountId: `${element.id || "aid-map"}-cluster-count` clusterCountId: `${element.id || "aid-map"}-cluster-count`,
statusBadge: element.parentElement?.querySelector("[data-map-status-badge]") || null
} }
state.markLoading = () => { state.markLoading = () => {
@ -167,16 +189,33 @@ const createAidMap = element => {
state.element.dataset.mapReady = "true" state.element.dataset.mapReady = "true"
} }
state.showUnavailable = () => { state.retry = () => {
if (!state.active) return
state.element.dataset.mapReady = "false"
delete state.element.dataset.mapUnavailable
delete state.element.dataset.mapUnavailableReason
state.element.replaceChildren()
state.mount()
}
state.showUnavailable = (reason, error = null) => {
state.map = null state.map = null
state.element.dataset.mapUnavailable = "true" reportMapUnavailable(state.element, reason, error)
state.markReady() state.markReady()
if (state.statusBadge) {
state.statusBadge.textContent = state.statusBadge.dataset.staticLabel
}
if (state.element.dataset.demoMap !== "true") { if (state.element.dataset.demoMap !== "true") {
const fallback = document.createElement("p") const fallback = document.createElement("div")
fallback.className = "grid h-full place-items-center p-6 text-center text-sm" fallback.className = "map-unavailable-panel"
fallback.dataset.mapFallback = "true" fallback.dataset.mapFallback = "true"
fallback.textContent = state.element.dataset.mapUnavailableLabel
const message = document.createElement("p")
message.textContent = state.element.dataset.mapUnavailableLabel
fallback.append(message, mapRetryButton(state.element, state.retry))
state.element.replaceChildren(fallback) state.element.replaceChildren(fallback)
return return
} }
@ -216,7 +255,7 @@ const createAidMap = element => {
points.append(item) points.append(item)
}) })
fallback.append(points) fallback.append(points, mapRetryButton(state.element, state.retry))
state.element.replaceChildren(fallback) state.element.replaceChildren(fallback)
} }
@ -401,8 +440,10 @@ const createAidMap = element => {
state.markLoading() state.markLoading()
const demoMap = state.element.dataset.demoMap === "true" const demoMap = state.element.dataset.demoMap === "true"
if (!supportsMapCanvas()) { const support = mapCanvasSupport()
state.showUnavailable()
if (!support.supported) {
state.showUnavailable(support.reason, support.error)
return return
} }
@ -414,14 +455,13 @@ const createAidMap = element => {
zoom: 5, zoom: 5,
attributionControl: true, attributionControl: true,
renderWorldCopies: false, renderWorldCopies: false,
maxBounds: [[-180, -85.05112878], [180, 85.05112878]],
trackResize: false, trackResize: false,
dragRotate: !demoMap, dragRotate: !demoMap,
pitchWithRotate: !demoMap, pitchWithRotate: !demoMap,
scrollZoom: !demoMap scrollZoom: !demoMap
}) })
} catch (_error) { } catch (error) {
state.showUnavailable() state.showUnavailable("initialization-failed", error)
return return
} }
@ -447,7 +487,14 @@ const createAidMap = element => {
state.onLoad = () => { state.onLoad = () => {
if (!state.active || !state.map) return if (!state.active || !state.map) return
delete state.element.dataset.mapUnavailable
delete state.element.dataset.mapUnavailableReason
state.markReady() state.markReady()
if (state.statusBadge) {
state.statusBadge.textContent = state.statusBadge.dataset.interactiveLabel
}
state.applySavedViewport() state.applySavedViewport()
state.renderMarkers() state.renderMarkers()
@ -913,11 +960,35 @@ export const Hooks = {
this.statusElement = this.el.querySelector(this.el.dataset.statusTarget) this.statusElement = this.el.querySelector(this.el.dataset.statusTarget)
} }
this.renderMapUnavailable = () => { this.retryMap = () => {
if (!this.mapElement) return if (!this.mapElement) return
this.mapElement.dataset.mapUnavailable = "true" this.mapUnavailable = false
this.mapElement.textContent = this.mapElement.dataset.mapUnavailableLabel delete this.el.dataset.mapUnavailable
this.mapElement.dataset.mapReady = "false"
delete this.mapElement.dataset.mapUnavailable
delete this.mapElement.dataset.mapUnavailableReason
this.mapElement.replaceChildren()
this.mountMap()
}
this.renderMapUnavailable = (reason = "webgl-context-unavailable", error = null) => {
if (!this.mapElement) return
this.mapUnavailableReason = reason
this.mapUnavailableError = error
this.el.dataset.mapUnavailable = "true"
reportMapUnavailable(this.mapElement, reason, error)
this.mapElement.dataset.mapReady = "true"
const fallback = document.createElement("div")
fallback.className = "map-unavailable-panel"
fallback.dataset.mapFallback = "true"
const message = document.createElement("p")
message.textContent = this.mapElement.dataset.mapUnavailableLabel
fallback.append(message, mapRetryButton(this.mapElement, this.retryMap))
this.mapElement.replaceChildren(fallback)
} }
this.mode = () => this.mode = () =>
@ -1199,16 +1270,21 @@ export const Hooks = {
this.el.addEventListener("change", this.onChange) this.el.addEventListener("change", this.onChange)
this.el.addEventListener("click", this.onClick) this.el.addEventListener("click", this.onClick)
if (!this.mapElement || !supportsMapCanvas()) { this.mountMap = () => {
if (this.mapElement) { if (!this.mapElement) return
const support = mapCanvasSupport()
if (!support.supported) {
this.mapUnavailable = true this.mapUnavailable = true
this.renderMapUnavailable() this.renderMapUnavailable(support.reason, support.error)
}
this.syncMode() this.syncMode()
return return
} }
const initialCoordinates = this.coordinates() const initialCoordinates = this.coordinates()
try {
this.map = new maplibregl.Map({ this.map = new maplibregl.Map({
container: this.mapElement, container: this.mapElement,
style: defaultStyle, style: defaultStyle,
@ -1220,12 +1296,24 @@ export const Hooks = {
dragRotate: false, dragRotate: false,
pitchWithRotate: false pitchWithRotate: false
}) })
} catch (error) {
this.map = null
this.mapUnavailable = true
this.renderMapUnavailable("initialization-failed", error)
this.syncMode()
return
}
this.map.touchZoomRotate.disableRotation() this.map.touchZoomRotate.disableRotation()
this.map.addControl(new maplibregl.NavigationControl({showCompass: false}), "top-left") this.map.addControl(new maplibregl.NavigationControl({showCompass: false}), "top-left")
this.map.on("load", () => { this.map.on("load", () => {
this.mapLoaded = true this.mapLoaded = true
this.mapUnavailable = false
delete this.el.dataset.mapUnavailable
this.mapElement.dataset.mapReady = "true" this.mapElement.dataset.mapReady = "true"
delete this.mapElement.dataset.mapUnavailable
delete this.mapElement.dataset.mapUnavailableReason
this.ensureAreaLayers() this.ensureAreaLayers()
this.renderSelection() this.renderSelection()
}) })
@ -1245,10 +1333,21 @@ export const Hooks = {
}) })
this.syncMode() this.syncMode()
}
this.mountMap()
}, },
updated() { updated() {
this.cacheElements() this.cacheElements()
if (this.mapUnavailable) this.renderMapUnavailable()
if (this.mapUnavailable) {
this.el.dataset.mapUnavailable = "true"
if (!this.mapElement?.querySelector("[data-map-fallback=true]")) {
this.renderMapUnavailable(this.mapUnavailableReason, this.mapUnavailableError)
}
}
this.syncMode() this.syncMode()
}, },
destroyed() { destroyed() {
@ -1367,7 +1466,11 @@ export const Hooks = {
} }
} }
function supportsMapCanvas() { function mapCanvasSupport() {
if (!window.WebGLRenderingContext) {
return {supported: false, reason: "webgl-api-unavailable"}
}
const canvas = document.createElement("canvas") const canvas = document.createElement("canvas")
const attributes = { const attributes = {
alpha: true, alpha: true,
@ -1381,10 +1484,13 @@ function supportsMapCanvas() {
canvas.getContext("webgl2", attributes) || canvas.getContext("webgl2", attributes) ||
canvas.getContext("webgl", attributes) canvas.getContext("webgl", attributes)
if (!context) return false if (!context || typeof context.getParameter !== "function") {
return {supported: false, reason: "webgl-context-unavailable"}
}
return true context.getExtension("WEBGL_lose_context")?.loseContext()
} catch (_error) { return {supported: true}
return false } catch (error) {
return {supported: false, reason: "webgl-context-disabled", error}
} }
} }

View File

@ -1,13 +1,13 @@
services: services:
migrate: migrate:
image: who-need-help:e2e image: ${APP_IMAGE:?Set APP_IMAGE for the isolated E2E run}
pull_policy: never pull_policy: never
build: build:
args: args:
WNH_E2E_ROUTES: "true" WNH_E2E_ROUTES: "true"
web: web:
image: who-need-help:e2e image: ${APP_IMAGE:?Set APP_IMAGE for the isolated E2E run}
pull_policy: never pull_policy: never
labels: labels:
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.rule=${TRAEFIK_ROUTER_RULE} - traefik.http.routers.${TRAEFIK_APP_NAME}-tls.rule=${TRAEFIK_ROUTER_RULE}
@ -17,11 +17,11 @@ services:
- traefik.http.routers.${TRAEFIK_APP_NAME}-tls.tls=true - traefik.http.routers.${TRAEFIK_APP_NAME}-tls.tls=true
worker: worker:
image: who-need-help:e2e image: ${APP_IMAGE:?Set APP_IMAGE for the isolated E2E run}
pull_policy: never pull_policy: never
e2e: e2e:
image: who-need-help-e2e-tests:local image: ${E2E_TEST_IMAGE:?Set E2E_TEST_IMAGE for the isolated E2E run}
build: build:
context: e2e context: e2e
init: true init: true

View File

@ -205,7 +205,16 @@ export function captureBrowserFailures(
page.on("console", (message) => { page.on("console", (message) => {
if (message.type() === "error") { if (message.type() === "error") {
failures.push(`console: ${message.text()}`); const text = message.text();
const abortedE2eRasterTile =
text.includes("AJAXError: Failed to fetch (0):") &&
text.includes("/__e2e__/map-tile.png");
// Chromium reports an aborted MapLibre worker fetch as both a console
// error and a request failure when navigation destroys the old map.
// requestfailed below remains authoritative: it ignores only explicit
// browser cancellation and still records genuine tile failures.
if (!abortedE2eRasterTile) failures.push(`console: ${text}`);
} }
}); });

View File

@ -22,3 +22,48 @@ test("public safety pages load and private discovery requires authentication", a
assertNoBrowserFailures(); assertNoBrowserFailures();
}); });
test("public demo labels the non-WebGL fallback honestly and offers retry", async ({
page,
}) => {
const assertNoBrowserFailures = captureBrowserFailures(page);
const mapWarnings: string[] = [];
page.on("console", (message) => {
if (message.type() === "warning" && message.text().includes("Map unavailable")) {
mapWarnings.push(message.text());
}
});
await page.addInitScript(() => {
const originalGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (
contextId: string,
options?: unknown,
) {
if (contextId === "webgl" || contextId === "webgl2") return null;
return originalGetContext.call(this, contextId, options as never);
} as typeof HTMLCanvasElement.prototype.getContext;
});
await page.goto("/");
const map = page.locator("#home-demo-map");
const status = page.locator("[data-map-status-badge]");
const retry = map.getByRole("button", { name: "Try map again" });
await map.scrollIntoViewIfNeeded();
await expect(map.locator("[data-map-fallback=true]")).toBeVisible();
await expect(status).toHaveText("Static preview");
await expect(retry).toBeVisible();
await expect(map).toHaveAttribute("data-map-unavailable-reason", "webgl-context-unavailable");
await expect.poll(() => mapWarnings.length).toBe(1);
await retry.click();
await expect.poll(() => mapWarnings.length).toBe(2);
await expect(status).toHaveText("Static preview");
await expect(retry).toBeVisible();
assertNoBrowserFailures();
});

View File

@ -147,15 +147,26 @@
data-map-ready="false" data-map-ready="false"
data-markers={demo_markers} data-markers={demo_markers}
data-map-unavailable-label={ data-map-unavailable-label={
gettext("The demo map is unavailable. The example steps remain available beside it.") gettext(
"The interactive demo could not start in this browser. You can retry or use the example steps beside it."
)
} }
data-map-retry-label={gettext("Try map again")}
role="region" role="region"
aria-label={gettext("Demo map with synthetic approximate help requests")} aria-label={gettext("Demo map with synthetic approximate help requests")}
class="aid-map home-demo-map" class="aid-map home-demo-map"
> >
</div> </div>
<div class="pointer-events-none absolute left-4 top-4 z-10 flex flex-wrap gap-2"> <div class="pointer-events-none absolute left-4 top-4 z-10 flex flex-wrap gap-2">
<span class="badge badge-neutral shadow-sm">{gettext("Interactive demo")}</span> <span
data-map-status-badge
data-interactive-label={gettext("Interactive demo")}
data-static-label={gettext("Static preview")}
aria-live="polite"
class="badge badge-neutral shadow-sm"
>
{gettext("Map preview")}
</span>
<span class="badge badge-ghost border border-base-300 bg-base-100/95 shadow-sm"> <span class="badge badge-ghost border border-base-300 bg-base-100/95 shadow-sm">
{gettext("Example data — not live requests")} {gettext("Example data — not live requests")}
</span> </span>

View File

@ -283,6 +283,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Index do
data-map-unavailable-label={ data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.") gettext("The map is unavailable in this browser. Request details remain usable.")
} }
data-map-retry-label={gettext("Try map again")}
class="aid-map" class="aid-map"
/> />
</aside> </aside>

View File

@ -539,6 +539,7 @@ defmodule WhoNeedHelpWeb.ActivityLive.Show do
data-map-unavailable-label={ data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.") gettext("The map is unavailable in this browser. Request details remain usable.")
} }
data-map-retry-label={gettext("Try map again")}
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

@ -514,6 +514,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Index do
data-map-unavailable-label={ data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.") gettext("The map is unavailable in this browser. Request details remain usable.")
} }
data-map-retry-label={gettext("Try map again")}
data-request-label={gettext("Open request")} data-request-label={gettext("Open request")}
class="aid-map request-discovery-map" class="aid-map request-discovery-map"
/> />

View File

@ -391,6 +391,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
data-point-marker-label={gettext("Drag to move the exact point")} data-point-marker-label={gettext("Drag to move the exact point")}
data-hidden-label={gettext("No map coordinates will be stored.")} data-hidden-label={gettext("No map coordinates will be stored.")}
data-location-unavailable-label={gettext("Location is unavailable in this browser.")} data-location-unavailable-label={gettext("Location is unavailable in this browser.")}
data-map-retry-label={gettext("Try map again")}
data-location-denied-label={gettext("Location permission was denied.")} data-location-denied-label={gettext("Location permission was denied.")}
class="request-location-workspace" class="request-location-workspace"
> >
@ -462,6 +463,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
data-map-unavailable-label={ data-map-unavailable-label={
gettext("The map is unavailable. You can use the manual coordinate fallback.") gettext("The map is unavailable. You can use the manual coordinate fallback.")
} }
data-map-retry-label={gettext("Try map again")}
class="aid-map request-location-map" class="aid-map request-location-map"
/> />
<div class="request-location-map-hint"> <div class="request-location-map-hint">

View File

@ -1045,6 +1045,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
data-map-unavailable-label={ data-map-unavailable-label={
gettext("The map is unavailable in this browser. Request details remain usable.") gettext("The map is unavailable in this browser. Request details remain usable.")
} }
data-map-retry-label={gettext("Try map again")}
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

View File

@ -477,7 +477,7 @@ cleanup() {
fi fi
docker rm -f "$container" >/dev/null 2>&1 || true docker rm -f "$container" >/dev/null 2>&1 || true
docker volume rm -f "$avd_volume" >/dev/null 2>&1 || true docker volume rm "$avd_volume" >/dev/null 2>&1 || true
if [[ -n "$remote_target" ]]; then if [[ -n "$remote_target" ]]; then
ssh "$remote_host" bash -s -- \ ssh "$remote_host" bash -s -- \
"$remote_root" "$remote_output_dir" "$remote_tools_image" \ "$remote_root" "$remote_output_dir" "$remote_tools_image" \

View File

@ -90,6 +90,7 @@ mkdir -p "$output"
cleanup() { cleanup() {
status=$? status=$?
trap - EXIT HUP INT TERM
if [ -n "$geo_pid" ]; then if [ -n "$geo_pid" ]; then
kill "$geo_pid" >/dev/null 2>&1 || true kill "$geo_pid" >/dev/null 2>&1 || true
@ -115,7 +116,7 @@ cleanup() {
fi fi
docker rm -f "$container" >/dev/null 2>&1 || true docker rm -f "$container" >/dev/null 2>&1 || true
docker volume rm -f "$avd_volume" >/dev/null 2>&1 || true docker volume rm "$avd_volume" >/dev/null 2>&1 || true
docker image rm "$image" >/dev/null 2>&1 || true docker image rm "$image" >/dev/null 2>&1 || true
exit "$status" exit "$status"
} }

View File

@ -78,7 +78,7 @@ cleanup() {
fi fi
docker rm -f "$container" >/dev/null 2>&1 || true docker rm -f "$container" >/dev/null 2>&1 || true
docker volume rm -f "$avd_volume" >/dev/null 2>&1 || true docker volume rm "$avd_volume" >/dev/null 2>&1 || true
docker image rm "$image" >/dev/null 2>&1 || true docker image rm "$image" >/dev/null 2>&1 || true
{ {

View File

@ -23,12 +23,13 @@ mkdir -p "$output_dir"
export TRAEFIK_PROJECT_CONSTRAINT="$project" export TRAEFIK_PROJECT_CONSTRAINT="$project"
export TRAEFIK_APP_NAME="who-need-help-e2e-$run_id" export TRAEFIK_APP_NAME="who-need-help-e2e-$run_id"
export TRAEFIK_DOCKER_NETWORK="${project}_ingress" export TRAEFIK_DOCKER_NETWORK="${project}_ingress"
# Compose interpolates required base-image variables before applying the E2E # Each run owns its image tags. The cleanup trap removes only these exact tags
# override. Keep those names local to the isolated runner; the application # after their project containers are gone, so parallel or interrupted runs do
# services themselves are still replaced with `who-need-help:e2e` below. # not accumulate shared test tags.
export APP_IMAGE="who-need-help:e2e" export APP_IMAGE="who-need-help:e2e-$run_id"
export SOCKET_PROXY_IMAGE="who-need-help:socket-proxy-e2e" export SOCKET_PROXY_IMAGE="who-need-help:socket-proxy-e2e-$run_id"
export POSTGIS_IMAGE="who-need-help:postgis-e2e" export POSTGIS_IMAGE="who-need-help:postgis-e2e-$run_id"
export E2E_TEST_IMAGE="who-need-help-e2e-tests:$run_id"
export E2E_OUTPUT_DIR="$output_dir" export E2E_OUTPUT_DIR="$output_dir"
export E2E_UID export E2E_UID
E2E_UID=$(id -u) E2E_UID=$(id -u)
@ -38,7 +39,7 @@ export CODEX_SESSION_ID="${CODEX_SESSION_ID:-${CODEX_THREAD_ID:-local-e2e}}"
# Keep raster-map traffic inside the isolated application network. MapLibre # Keep raster-map traffic inside the isolated application network. MapLibre
# performs tile fetches from a worker, so browser-context request interception # performs tile fetches from a worker, so browser-context request interception
# is not a reliable network boundary for these requests. # is not a reliable network boundary for these requests.
export MAP_TILE_URL="https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}" export MAP_TILE_URL="/__e2e__/map-tile.png?z={z}&x={x}&y={y}"
compose() { compose() {
docker compose \ docker compose \
@ -49,8 +50,36 @@ compose() {
"$@" "$@"
} }
image_tag_is_referenced() {
expected_image=$1
for container_id in $(docker ps -aq --filter "ancestor=$expected_image"); do
observed_image=$(docker inspect --format '{{.Config.Image}}' "$container_id")
if [ "$observed_image" = "$expected_image" ]; then
return 0
fi
done
return 1
}
remove_run_image() {
image=$1
if docker image inspect "$image" >/dev/null 2>&1; then
if image_tag_is_referenced "$image"; then
echo "Refusing to remove referenced E2E image: $image" >&2
cleanup_status=1
else
docker image rm "$image" >/dev/null || cleanup_status=$?
fi
fi
}
cleanup() { cleanup() {
status=$? status=$?
cleanup_status=0
trap - EXIT HUP INT TERM
if [ "$status" -ne 0 ]; then if [ "$status" -ne 0 ]; then
compose ps --all >"$output_dir/compose-ps.txt" 2>&1 || true compose ps --all >"$output_dir/compose-ps.txt" 2>&1 || true
@ -61,7 +90,35 @@ cleanup() {
echo "Keeping isolated E2E project $project for inspection." echo "Keeping isolated E2E project $project for inspection."
echo "Artifacts: $output_dir" echo "Artifacts: $output_dir"
else else
compose down --volumes --remove-orphans >/dev/null compose down --remove-orphans >/dev/null 2>&1 || cleanup_status=$?
postgres_volume="${project}_postgres_data"
if docker volume inspect "$postgres_volume" >/dev/null 2>&1; then
observed_project=$(docker volume inspect --format \
'{{index .Labels "com.docker.compose.project"}}' "$postgres_volume")
observed_volume=$(docker volume inspect --format \
'{{index .Labels "com.docker.compose.volume"}}' "$postgres_volume")
remaining_refs=$(docker ps -aq --filter "volume=$postgres_volume")
if [ "$observed_project" != "$project" ] || \
[ "$observed_volume" != "postgres_data" ] || \
[ -n "$remaining_refs" ]; then
echo "Refusing to remove unexpected or referenced E2E volume: $postgres_volume" >&2
cleanup_status=1
else
docker volume rm "$postgres_volume" >/dev/null || cleanup_status=$?
fi
fi
remove_run_image "$E2E_TEST_IMAGE"
remove_run_image "$APP_IMAGE"
remove_run_image "$POSTGIS_IMAGE"
remove_run_image "$SOCKET_PROXY_IMAGE"
fi
if [ "$status" -eq 0 ] && [ "$cleanup_status" -ne 0 ]; then
status=$cleanup_status
fi fi
exit "$status" exit "$status"

View File

@ -17,13 +17,15 @@ case "$project" in
esac esac
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
run_id=${project#who_need_help_e2e_}
# Compose still resolves required interpolation values for `down`. These values # Compose still resolves required interpolation values for `down`. These values
# mirror the isolated runner and do not select containers; the project label # mirror the exact per-run tags. The project label passed above remains the
# passed above is the scope used by Compose for removal. # Compose resource scope.
export APP_IMAGE="who-need-help:e2e" export APP_IMAGE="who-need-help:e2e-$run_id"
export SOCKET_PROXY_IMAGE="who-need-help:socket-proxy-e2e" export SOCKET_PROXY_IMAGE="who-need-help:socket-proxy-e2e-$run_id"
export POSTGIS_IMAGE="who-need-help:postgis-e2e" export POSTGIS_IMAGE="who-need-help:postgis-e2e-$run_id"
export E2E_TEST_IMAGE="who-need-help-e2e-tests:$run_id"
export E2E_OUTPUT_DIR="$ROOT/output/e2e/stop-placeholder" export E2E_OUTPUT_DIR="$ROOT/output/e2e/stop-placeholder"
export E2E_UID export E2E_UID
E2E_UID=$(id -u) E2E_UID=$(id -u)
@ -32,11 +34,51 @@ E2E_GID=$(id -g)
export TRAEFIK_PROJECT_CONSTRAINT="$project" export TRAEFIK_PROJECT_CONSTRAINT="$project"
export TRAEFIK_APP_NAME="$project" export TRAEFIK_APP_NAME="$project"
export TRAEFIK_DOCKER_NETWORK="${project}_ingress" export TRAEFIK_DOCKER_NETWORK="${project}_ingress"
export MAP_TILE_URL="https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y}" export MAP_TILE_URL="/__e2e__/map-tile.png?z={z}&x={x}&y={y}"
docker compose \ docker compose \
--project-name "$project" \ --project-name "$project" \
--env-file "$ROOT/.env.e2e" \ --env-file "$ROOT/.env.e2e" \
--file "$ROOT/compose.yaml" \ --file "$ROOT/compose.yaml" \
--file "$ROOT/compose.e2e.yaml" \ --file "$ROOT/compose.e2e.yaml" \
down --volumes --remove-orphans down --remove-orphans
postgres_volume="${project}_postgres_data"
if docker volume inspect "$postgres_volume" >/dev/null 2>&1; then
observed_project=$(docker volume inspect --format \
'{{index .Labels "com.docker.compose.project"}}' "$postgres_volume")
observed_volume=$(docker volume inspect --format \
'{{index .Labels "com.docker.compose.volume"}}' "$postgres_volume")
remaining_refs=$(docker ps -aq --filter "volume=$postgres_volume")
if [ "$observed_project" != "$project" ] || \
[ "$observed_volume" != "postgres_data" ] || \
[ -n "$remaining_refs" ]; then
echo "Refusing to remove unexpected or referenced E2E volume: $postgres_volume" >&2
exit 1
fi
docker volume rm "$postgres_volume"
fi
for image in "$E2E_TEST_IMAGE" "$APP_IMAGE" "$POSTGIS_IMAGE" "$SOCKET_PROXY_IMAGE"; do
if docker image inspect "$image" >/dev/null 2>&1; then
referenced=false
for container_id in $(docker ps -aq --filter "ancestor=$image"); do
observed_image=$(docker inspect --format '{{.Config.Image}}' "$container_id")
if [ "$observed_image" = "$image" ]; then
referenced=true
break
fi
done
if [ "$referenced" = true ]; then
echo "Refusing to remove referenced E2E image: $image" >&2
exit 1
fi
docker image rm "$image"
fi
done

View File

@ -24,6 +24,13 @@ if [ -f "$target" ]; then
updated=true updated=true
fi fi
if grep -Eq '^MAP_TILE_URL=https?://proxy/__e2e__/map-tile\.png' "$target"; then
sed -i \
's|^MAP_TILE_URL=https\?://proxy/__e2e__/map-tile\.png|MAP_TILE_URL=/__e2e__/map-tile.png|' \
"$target"
updated=true
fi
chmod 600 "$target" chmod 600 "$target"
if [ "$updated" = true ]; then if [ "$updated" = true ]; then
@ -59,7 +66,7 @@ TRAEFIK_ROUTER_RULE='PathPrefix(\`/\`)'
PHX_HOST=proxy PHX_HOST=proxy
PHX_SCHEME=https PHX_SCHEME=https
PHX_URL_PORT=443 PHX_URL_PORT=443
MAP_TILE_URL=https://proxy/__e2e__/map-tile.png?z={z}&x={x}&y={y} MAP_TILE_URL=/__e2e__/map-tile.png?z={z}&x={x}&y={y}
POSTGRES_DB=who_need_help_e2e POSTGRES_DB=who_need_help_e2e
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=$postgres_password POSTGRES_PASSWORD=$postgres_password

View File

@ -27,6 +27,29 @@ docker compose \
-p "$LOAD_PROJECT" \ -p "$LOAD_PROJECT" \
-f compose.yaml \ -f compose.yaml \
-f compose.load.yaml \ -f compose.load.yaml \
stop down --remove-orphans
echo "Stopped the isolated load-profile containers; its database volume remains." # Optional observability and backup overlays create additional networks which
# are not present in the base load Compose file. Remove only unattached
# networks carrying this exact isolated project label.
for network_id in $(docker network ls -q \
--filter "label=com.docker.compose.project=$LOAD_PROJECT"); do
observed_project=$(docker network inspect --format \
'{{index .Labels "com.docker.compose.project"}}' "$network_id")
endpoints=$(docker network inspect --format '{{len .Containers}}' "$network_id")
if [ "$observed_project" != "$LOAD_PROJECT" ] || [ "$endpoints" != 0 ]; then
echo "Refusing unexpected or connected load-profile network: $network_id" >&2
exit 1
fi
docker network rm "$network_id" >/dev/null
done
if image_id=$(docker image inspect --format '{{.Id}}' who-need-help:load-tools 2>/dev/null); then
if [ -z "$(docker ps -aq --filter "ancestor=$image_id")" ]; then
docker image rm who-need-help:load-tools >/dev/null
fi
fi
echo "Removed the isolated load-profile containers and networks; its named volumes remain."

View File

@ -34,14 +34,60 @@ case "$LOAD_WEB_REPLICAS:$LOAD_WORKER_REPLICAS" in
esac esac
cd "$ROOT" cd "$ROOT"
docker build --target load_tools --tag who-need-help:load-tools . compose() {
docker compose \
docker compose \
--env-file "$ENV_FILE" \ --env-file "$ENV_FILE" \
-p "$LOAD_PROJECT" \ -p "$LOAD_PROJECT" \
-f compose.yaml \ -f compose.yaml \
-f compose.load.yaml \ -f compose.load.yaml \
up -d --build --wait \ "$@"
}
cleanup_failed_start() {
status=$?
trap - EXIT HUP INT TERM
if [ "$status" -ne 0 ]; then
# A cancelled Docker API request can finish creating a network just after
# the first `compose down`. Repeat the exact project-scoped teardown until
# the project has no containers or networks, or the bounded retry expires.
attempt=0
while [ "$attempt" -lt 5 ]; do
compose down --remove-orphans >/dev/null 2>&1 || true
remaining_containers=$(docker ps -aq \
--filter "label=com.docker.compose.project=$LOAD_PROJECT")
remaining_networks=$(docker network ls -q \
--filter "label=com.docker.compose.project=$LOAD_PROJECT")
if [ -z "$remaining_containers" ] && [ -z "$remaining_networks" ]; then
break
fi
attempt=$((attempt + 1))
sleep 1
done
if image_id=$(docker image inspect --format '{{.Id}}' who-need-help:load-tools 2>/dev/null); then
if [ -z "$(docker ps -aq --filter "ancestor=$image_id")" ]; then
docker image rm who-need-help:load-tools >/dev/null 2>&1 || true
fi
fi
if [ -n "$(docker ps -aq --filter "label=com.docker.compose.project=$LOAD_PROJECT")" ] || \
[ -n "$(docker network ls -q --filter "label=com.docker.compose.project=$LOAD_PROJECT")" ]; then
echo "Load-profile startup cleanup left project resources behind." >&2
status=1
fi
fi
exit "$status"
}
trap cleanup_failed_start EXIT HUP INT TERM
docker build --target load_tools --tag who-need-help:load-tools .
compose up -d --build --wait \
--scale "web=$LOAD_WEB_REPLICAS" \ --scale "web=$LOAD_WEB_REPLICAS" \
--scale "worker=$LOAD_WORKER_REPLICAS" --scale "worker=$LOAD_WORKER_REPLICAS"
@ -65,3 +111,5 @@ curl --fail --silent --show-error \
"http://localhost:$HTTP_PORT/healthz/ready" "http://localhost:$HTTP_PORT/healthz/ready"
printf '\nLoad profile is ready with %s web and %s worker replicas.\n' \ printf '\nLoad profile is ready with %s web and %s worker replicas.\n' \
"$LOAD_WEB_REPLICAS" "$LOAD_WORKER_REPLICAS" "$LOAD_WEB_REPLICAS" "$LOAD_WORKER_REPLICAS"
trap - EXIT HUP INT TERM

View File

@ -41,6 +41,7 @@ export QUALITY_POSTGIS_IMAGE
compose="docker compose -p $project -f $ROOT/compose.quality.yaml" compose="docker compose -p $project -f $ROOT/compose.quality.yaml"
cleanup() { cleanup() {
trap - EXIT HUP INT TERM
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true $compose down --volumes --remove-orphans >/dev/null 2>&1 || true
docker rm --force "$socket_proxy_container" >/dev/null 2>&1 || true docker rm --force "$socket_proxy_container" >/dev/null 2>&1 || true
docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \ docker image rm "$quality_image" "$assets_image" "$e2e_image" "$release_image" \

View File

@ -22,15 +22,59 @@ fi
: "${POSTGRES_USER:?Set POSTGRES_USER in .env}" : "${POSTGRES_USER:?Set POSTGRES_USER in .env}"
: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}" : "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
docker compose -p who_need_help up -d --wait db run_id="$(date -u +%Y%m%d%H%M%S)-$$"
docker build --target test --tag who-need-help:test . image="who-need-help:test-$run_id"
container="who-need-help-test-$run_id"
exec docker run --rm \ image_tag_is_referenced() {
expected_image=$1
for container_id in $(docker ps -aq --filter "ancestor=$expected_image"); do
observed_image=$(docker inspect --format '{{.Config.Image}}' "$container_id")
if [ "$observed_image" = "$expected_image" ]; then
return 0
fi
done
return 1
}
cleanup() {
status=$?
cleanup_status=0
trap - EXIT HUP INT TERM
if docker inspect "$container" >/dev/null 2>&1; then
docker rm -f "$container" >/dev/null 2>&1 || cleanup_status=$?
fi
if docker image inspect "$image" >/dev/null 2>&1; then
if image_tag_is_referenced "$image"; then
echo "Refusing to remove referenced test image: $image" >&2
cleanup_status=1
else
docker image rm "$image" >/dev/null || cleanup_status=$?
fi
fi
if [ "$status" -eq 0 ] && [ "$cleanup_status" -ne 0 ]; then
status=$cleanup_status
fi
exit "$status"
}
trap cleanup EXIT HUP INT TERM
docker compose -p who_need_help up -d --wait db
docker build --target test --tag "$image" .
docker run --rm \
--name "$container" \
--network who_need_help_internal \ --network who_need_help_internal \
--env MIX_ENV=test \ --env MIX_ENV=test \
--env DB_HOST=db \ --env DB_HOST=db \
--env "DB_USER=$POSTGRES_USER" \ --env "DB_USER=$POSTGRES_USER" \
--env "DB_PASSWORD=$POSTGRES_PASSWORD" \ --env "DB_PASSWORD=$POSTGRES_PASSWORD" \
--env TEST_POOL_SIZE="${TEST_POOL_SIZE:-10}" \ --env TEST_POOL_SIZE="${TEST_POOL_SIZE:-10}" \
who-need-help:test \ "$image" \
mix test mix test

View File

@ -54,6 +54,20 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
) )
) == 1 ) == 1
assert Enum.count(
LazyHTML.query(
document,
"#home-demo-map[data-map-retry-label='Try map again']"
)
) == 1
assert Enum.count(
LazyHTML.query(
document,
"[data-map-status-badge][data-interactive-label='Interactive demo'][data-static-label='Static preview'][aria-live='polite']"
)
) == 1
assert Enum.count( assert Enum.count(
LazyHTML.query(document, "head link[rel='apple-touch-icon'][sizes='180x180']") LazyHTML.query(document, "head link[rel='apple-touch-icon'][sizes='180x180']")
) == 1 ) == 1