test: add scoped public staging handover E2E
This commit is contained in:
parent
5b723831af
commit
378d0b4563
|
|
@ -20,6 +20,12 @@ export type AuthenticatedBrowser = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function projectEmail(localPart: string, projectName: string): string {
|
export function projectEmail(localPart: string, projectName: string): string {
|
||||||
|
const runID = process.env.E2E_RUN_ID;
|
||||||
|
|
||||||
|
if (runID) {
|
||||||
|
return `wnh-staging-e2e-${runID}-${localPart}@example.invalid`;
|
||||||
|
}
|
||||||
|
|
||||||
const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||||
return `${localPart}+${scope}@example.invalid`;
|
return `${localPart}+${scope}@example.invalid`;
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +38,12 @@ export async function newIsolatedContext(browser: Browser): Promise<BrowserConte
|
||||||
return browser.newContext({ serviceWorkers: "block" });
|
return browser.newContext({ serviceWorkers: "block" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function waitForLiveViewConnected(page: Page): Promise<void> {
|
||||||
|
await expect(page.locator("[data-phx-main].phx-connected")).toHaveCount(1, {
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForMapReady(page: Page): Promise<void> {
|
export async function waitForMapReady(page: Page): Promise<void> {
|
||||||
const map = page.locator(".aid-map");
|
const map = page.locator(".aid-map");
|
||||||
|
|
||||||
|
|
@ -70,13 +82,18 @@ export function captureBrowserFailures(page: Page): () => void {
|
||||||
|
|
||||||
page.on("requestfailed", (request) => {
|
page.on("requestfailed", (request) => {
|
||||||
const failure = request.failure()?.errorText ?? "";
|
const failure = request.failure()?.errorText ?? "";
|
||||||
|
const cancelled =
|
||||||
if (
|
failure.includes("Load request cancelled") ||
|
||||||
request.url().includes("/__e2e__/map-tile.png") &&
|
|
||||||
(failure.includes("Load request cancelled") ||
|
|
||||||
failure.includes("ERR_ABORTED") ||
|
failure.includes("ERR_ABORTED") ||
|
||||||
failure.includes("NS_BINDING_ABORTED"))
|
failure.includes("NS_BINDING_ABORTED");
|
||||||
) {
|
const rasterTile =
|
||||||
|
request.url().includes("/__e2e__/map-tile.png") ||
|
||||||
|
/\/\d+\/\d+\/\d+\.png(?:\?|$)/.test(request.url());
|
||||||
|
|
||||||
|
// MapLibre cancels in-flight raster tiles when a LiveView navigation
|
||||||
|
// destroys or re-centres a map. Actual tile/network failures retain their
|
||||||
|
// engine error and remain test failures.
|
||||||
|
if (rasterTile && cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,11 +219,30 @@ export async function loginWithMagicLink(
|
||||||
return { context, page, email };
|
return { context, page, email };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loginWithPassword(
|
||||||
|
browser: Browser,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<AuthenticatedBrowser> {
|
||||||
|
const context = await newIsolatedContext(browser);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto("/users/log-in");
|
||||||
|
const form = page.locator("#login_form_password");
|
||||||
|
await form.getByLabel("Email").fill(email);
|
||||||
|
await form.getByLabel("Password").fill(password);
|
||||||
|
await form.getByRole("button", { name: "Log in only this time" }).click();
|
||||||
|
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
return { context, page, email };
|
||||||
|
}
|
||||||
|
|
||||||
export async function selectOptionContaining(
|
export async function selectOptionContaining(
|
||||||
page: Page,
|
page: Page,
|
||||||
label: string,
|
label: string,
|
||||||
expectedText: string,
|
expectedText: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
await waitForLiveViewConnected(page);
|
||||||
const select = page.getByLabel(label);
|
const select = page.getByLabel(label);
|
||||||
const options = await select.locator("option").evaluateAll((nodes) =>
|
const options = await select.locator("option").evaluateAll((nodes) =>
|
||||||
nodes.map((node) => ({
|
nodes.map((node) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
captureBrowserFailures,
|
captureBrowserFailures,
|
||||||
|
loginWithPassword,
|
||||||
projectEmail,
|
projectEmail,
|
||||||
projectText,
|
projectText,
|
||||||
registerAndConfirm,
|
registerAndConfirm,
|
||||||
selectOptionContaining,
|
selectOptionContaining,
|
||||||
|
waitForLiveViewConnected,
|
||||||
waitForMapReady,
|
waitForMapReady,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
|
|
@ -12,19 +14,22 @@ test("two users complete a medicine handover with realtime chat and blind review
|
||||||
browser,
|
browser,
|
||||||
request,
|
request,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const requestTitle = projectText("E2E medicine pickup", testInfo.project.name);
|
const runID = process.env.E2E_RUN_ID;
|
||||||
const requester = await registerAndConfirm(
|
const fixturePassword = process.env.E2E_FIXTURE_PASSWORD;
|
||||||
browser,
|
const requestTitle = projectText(
|
||||||
request,
|
runID ? `Staging E2E medicine pickup ${runID}` : "E2E medicine pickup",
|
||||||
projectEmail("requester", testInfo.project.name),
|
testInfo.project.name,
|
||||||
"E2E Requester",
|
|
||||||
);
|
|
||||||
const helper = await registerAndConfirm(
|
|
||||||
browser,
|
|
||||||
request,
|
|
||||||
projectEmail("helper", testInfo.project.name),
|
|
||||||
"E2E Helper",
|
|
||||||
);
|
);
|
||||||
|
const requesterEmail = projectEmail("requester", testInfo.project.name);
|
||||||
|
const helperEmail = projectEmail("helper", testInfo.project.name);
|
||||||
|
const requester =
|
||||||
|
runID && fixturePassword
|
||||||
|
? await loginWithPassword(browser, requesterEmail, fixturePassword)
|
||||||
|
: await registerAndConfirm(browser, request, requesterEmail, "E2E Requester");
|
||||||
|
const helper =
|
||||||
|
runID && fixturePassword
|
||||||
|
? await loginWithPassword(browser, helperEmail, fixturePassword)
|
||||||
|
: await registerAndConfirm(browser, request, helperEmail, "E2E Helper");
|
||||||
const assertRequesterClean = captureBrowserFailures(requester.page);
|
const assertRequesterClean = captureBrowserFailures(requester.page);
|
||||||
const assertHelperClean = captureBrowserFailures(helper.page);
|
const assertHelperClean = captureBrowserFailures(helper.page);
|
||||||
|
|
||||||
|
|
@ -51,6 +56,7 @@ test("two users complete a medicine handover with realtime chat and blind review
|
||||||
await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible();
|
await expect(requester.page.getByRole("heading", { name: requestTitle })).toBeVisible();
|
||||||
|
|
||||||
await helper.page.goto(requestURL);
|
await helper.page.goto(requestURL);
|
||||||
|
await waitForLiveViewConnected(helper.page);
|
||||||
await helper.page.getByRole("button", { name: "I can help" }).click();
|
await helper.page.getByRole("button", { name: "I can help" }).click();
|
||||||
await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
await expect(helper.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
||||||
await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
||||||
|
|
@ -94,6 +100,7 @@ test("two users complete a medicine handover with realtime chat and blind review
|
||||||
await expect(requester.page.getByText("None revealed yet.")).toBeVisible();
|
await expect(requester.page.getByText("None revealed yet.")).toBeVisible();
|
||||||
await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0);
|
await expect(requester.page.getByText("Clear and safe coordination.")).toHaveCount(0);
|
||||||
await requester.page.goto(requestURL);
|
await requester.page.goto(requestURL);
|
||||||
|
await waitForLiveViewConnected(requester.page);
|
||||||
|
|
||||||
await requester.page.getByLabel("Rating").selectOption("5");
|
await requester.page.getByLabel("Rating").selectOption("5");
|
||||||
await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer.");
|
await requester.page.getByLabel("Comment (optional)").fill("Reliable volunteer.");
|
||||||
|
|
|
||||||
328
lib/mix/tasks/wnh.staging_e2e.ex
Normal file
328
lib/mix/tasks/wnh.staging_e2e.ex
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
defmodule Mix.Tasks.Wnh.StagingE2e do
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias WhoNeedHelp.Accounts.User
|
||||||
|
alias WhoNeedHelp.Activities.Activity
|
||||||
|
alias WhoNeedHelp.Catalog.CategoryProposal
|
||||||
|
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
|
||||||
|
alias WhoNeedHelp.Messaging.Message
|
||||||
|
alias WhoNeedHelp.Repo
|
||||||
|
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
|
||||||
|
alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, Report, Review}
|
||||||
|
|
||||||
|
@shortdoc "Prepares or removes an exact public-staging browser fixture"
|
||||||
|
@confirmation "public-staging-e2e"
|
||||||
|
|
||||||
|
@impl Mix.Task
|
||||||
|
def run([action]) when action in ["prepare", "cleanup"] do
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
context = verified_context()
|
||||||
|
|
||||||
|
case action do
|
||||||
|
"prepare" -> prepare(context)
|
||||||
|
"cleanup" -> cleanup(context)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def run(_args) do
|
||||||
|
Mix.raise("usage: mix wnh.staging_e2e prepare|cleanup")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verified_context do
|
||||||
|
run_id = required_env!("WNH_STAGING_E2E_RUN_ID")
|
||||||
|
expected_database = required_env!("WNH_STAGING_E2E_EXPECTED_DATABASE")
|
||||||
|
manifest_path = required_env!("WNH_STAGING_E2E_MANIFEST_PATH") |> Path.expand()
|
||||||
|
|
||||||
|
unless Regex.match?(~r/^[a-z0-9-]+$/, run_id) do
|
||||||
|
Mix.raise("WNH_STAGING_E2E_RUN_ID may contain only lowercase letters, numbers, and dash")
|
||||||
|
end
|
||||||
|
|
||||||
|
unless System.get_env("WNH_STAGING_E2E_CONFIRM") == @confirmation do
|
||||||
|
Mix.raise("WNH_STAGING_E2E_CONFIRM must equal #{@confirmation}")
|
||||||
|
end
|
||||||
|
|
||||||
|
unless String.starts_with?(manifest_path, "/output/") do
|
||||||
|
Mix.raise("WNH_STAGING_E2E_MANIFEST_PATH must resolve below /output")
|
||||||
|
end
|
||||||
|
|
||||||
|
%Postgrex.Result{rows: [[actual_database]]} =
|
||||||
|
Repo.query!("SELECT current_database()", [], log: false)
|
||||||
|
|
||||||
|
unless actual_database == expected_database do
|
||||||
|
Mix.raise(
|
||||||
|
"refusing staging E2E mutation: expected database #{inspect(expected_database)}, " <>
|
||||||
|
"observed #{inspect(actual_database)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
%{run_id: run_id, database: actual_database, manifest_path: manifest_path}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prepare(context) do
|
||||||
|
password = required_env!("WNH_STAGING_E2E_PASSWORD")
|
||||||
|
|
||||||
|
unless byte_size(password) in 12..72 do
|
||||||
|
Mix.raise("WNH_STAGING_E2E_PASSWORD must contain between 12 and 72 bytes")
|
||||||
|
end
|
||||||
|
|
||||||
|
emails = emails(context.run_id)
|
||||||
|
|
||||||
|
if Repo.exists?(from(user in User, where: user.email in ^Map.values(emails))) do
|
||||||
|
Mix.raise("staging E2E users already exist for #{inspect(context.run_id)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
password_hash = Bcrypt.hash_pwd_salt(password)
|
||||||
|
now = DateTime.utc_now(:second)
|
||||||
|
|
||||||
|
{:ok, users} =
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
%{
|
||||||
|
requester: insert_user!(emails.requester, "Staging E2E Requester", password_hash, now),
|
||||||
|
helper: insert_user!(emails.helper, "Staging E2E Helper", password_hash, now)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
manifest = %{
|
||||||
|
"schema_version" => 1,
|
||||||
|
"run_id" => context.run_id,
|
||||||
|
"database" => context.database,
|
||||||
|
"requester_id" => users.requester.id,
|
||||||
|
"requester_email" => users.requester.email,
|
||||||
|
"helper_id" => users.helper.id,
|
||||||
|
"helper_email" => users.helper.email
|
||||||
|
}
|
||||||
|
|
||||||
|
context.manifest_path |> Path.dirname() |> File.mkdir_p!()
|
||||||
|
File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
|
||||||
|
File.chmod!(context.manifest_path, 0o600)
|
||||||
|
|
||||||
|
Mix.shell().info("prepared exact staging E2E users for #{context.run_id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cleanup(context) do
|
||||||
|
manifest = context.manifest_path |> File.read!() |> Jason.decode!()
|
||||||
|
validate_manifest!(context, manifest)
|
||||||
|
|
||||||
|
requester_id = manifest["requester_id"]
|
||||||
|
helper_id = manifest["helper_id"]
|
||||||
|
user_ids = [requester_id, helper_id]
|
||||||
|
|
||||||
|
request_ids =
|
||||||
|
HelpRequest
|
||||||
|
|> where([request], request.requester_id == ^requester_id)
|
||||||
|
|> select([request], request.id)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
assignment_ids =
|
||||||
|
Assignment
|
||||||
|
|> where([assignment], assignment.request_id in ^request_ids)
|
||||||
|
|> select([assignment], assignment.id)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
validate_owned_domain!(user_ids, request_ids, assignment_ids, helper_id)
|
||||||
|
|
||||||
|
{:ok, deleted} =
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
tracking_session_ids =
|
||||||
|
TrackingSession
|
||||||
|
|> where([session], session.assignment_id in ^assignment_ids)
|
||||||
|
|> select([session], session.id)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
message_ids =
|
||||||
|
Message
|
||||||
|
|> where([message], message.assignment_id in ^assignment_ids)
|
||||||
|
|> select([message], message.id)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
reports =
|
||||||
|
Report
|
||||||
|
|> where(
|
||||||
|
[report],
|
||||||
|
report.reporter_id in ^user_ids or report.request_id in ^request_ids or
|
||||||
|
report.assignment_id in ^assignment_ids or report.message_id in ^message_ids
|
||||||
|
)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
audits =
|
||||||
|
AuditEvent
|
||||||
|
|> where(
|
||||||
|
[event],
|
||||||
|
event.actor_id in ^user_ids or
|
||||||
|
(event.target_type == "request" and event.target_id in ^request_ids) or
|
||||||
|
(event.target_type == "assignment" and event.target_id in ^assignment_ids)
|
||||||
|
)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
positions =
|
||||||
|
Position
|
||||||
|
|> where([position], position.tracking_session_id in ^tracking_session_ids)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
sessions =
|
||||||
|
TrackingSession
|
||||||
|
|> where([session], session.id in ^tracking_session_ids)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
messages =
|
||||||
|
Message
|
||||||
|
|> where([message], message.assignment_id in ^assignment_ids)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
abuse_signals =
|
||||||
|
AbuseSignal
|
||||||
|
|> where(
|
||||||
|
[signal],
|
||||||
|
signal.subject_id in ^user_ids or signal.assignment_id in ^assignment_ids
|
||||||
|
)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
reviews =
|
||||||
|
Review
|
||||||
|
|> where(
|
||||||
|
[review],
|
||||||
|
review.assignment_id in ^assignment_ids or review.reviewer_id in ^user_ids or
|
||||||
|
review.reviewee_id in ^user_ids
|
||||||
|
)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
assignments =
|
||||||
|
Assignment
|
||||||
|
|> where([assignment], assignment.id in ^assignment_ids)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
requests =
|
||||||
|
HelpRequest
|
||||||
|
|> where([request], request.id in ^request_ids)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
users =
|
||||||
|
User
|
||||||
|
|> where([user], user.id in ^user_ids)
|
||||||
|
|> Repo.delete_all()
|
||||||
|
|> elem(0)
|
||||||
|
|
||||||
|
%{
|
||||||
|
reports: reports,
|
||||||
|
audit_events: audits,
|
||||||
|
tracking_positions: positions,
|
||||||
|
tracking_sessions: sessions,
|
||||||
|
messages: messages,
|
||||||
|
abuse_signals: abuse_signals,
|
||||||
|
reviews: reviews,
|
||||||
|
assignments: assignments,
|
||||||
|
requests: requests,
|
||||||
|
users: users
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
unless deleted.users == 2 do
|
||||||
|
Mix.raise("cleanup removed #{deleted.users} users instead of exactly 2")
|
||||||
|
end
|
||||||
|
|
||||||
|
Mix.shell().info("removed exact staging E2E fixture: #{inspect(deleted)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_manifest!(context, manifest) do
|
||||||
|
expected_emails = emails(context.run_id)
|
||||||
|
|
||||||
|
unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
|
||||||
|
manifest["database"] == context.database and
|
||||||
|
manifest["requester_email"] == expected_emails.requester and
|
||||||
|
manifest["helper_email"] == expected_emails.helper and
|
||||||
|
uuid?(manifest["requester_id"]) and uuid?(manifest["helper_id"]) do
|
||||||
|
Mix.raise("staging E2E manifest does not match the requested run and database")
|
||||||
|
end
|
||||||
|
|
||||||
|
expected =
|
||||||
|
MapSet.new([
|
||||||
|
{manifest["requester_id"], manifest["requester_email"]},
|
||||||
|
{manifest["helper_id"], manifest["helper_email"]}
|
||||||
|
])
|
||||||
|
|
||||||
|
observed =
|
||||||
|
User
|
||||||
|
|> where([user], user.id in ^[manifest["requester_id"], manifest["helper_id"]])
|
||||||
|
|> select([user], {user.id, user.email})
|
||||||
|
|> Repo.all()
|
||||||
|
|> MapSet.new()
|
||||||
|
|
||||||
|
unless observed == expected do
|
||||||
|
Mix.raise("staging E2E user ownership does not match the manifest")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_owned_domain!(user_ids, request_ids, assignment_ids, helper_id) do
|
||||||
|
unexpected_request? =
|
||||||
|
Repo.exists?(
|
||||||
|
from(request in HelpRequest,
|
||||||
|
where: request.requester_id in ^user_ids and request.id not in ^request_ids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
unexpected_assignment? =
|
||||||
|
Repo.exists?(
|
||||||
|
from(assignment in Assignment,
|
||||||
|
where:
|
||||||
|
assignment.helper_id in ^user_ids and
|
||||||
|
(assignment.id not in ^assignment_ids or assignment.helper_id != ^helper_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
unexpected_activity? =
|
||||||
|
Repo.exists?(from(activity in Activity, where: activity.creator_id in ^user_ids))
|
||||||
|
|
||||||
|
unexpected_proposal? =
|
||||||
|
Repo.exists?(from(proposal in CategoryProposal, where: proposal.proposer_id in ^user_ids))
|
||||||
|
|
||||||
|
unless length(request_ids) <= 1 and length(assignment_ids) <= 1 and
|
||||||
|
not unexpected_request? and not unexpected_assignment? and
|
||||||
|
not unexpected_activity? and not unexpected_proposal? do
|
||||||
|
Mix.raise("staging E2E users own records outside the exact medicine-flow scope")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp insert_user!(email, display_name, password_hash, now) do
|
||||||
|
%User{}
|
||||||
|
|> User.registration_changeset(%{
|
||||||
|
"email" => email,
|
||||||
|
"display_name" => display_name,
|
||||||
|
"locale" => "en",
|
||||||
|
"terms_accepted" => true
|
||||||
|
})
|
||||||
|
|> Ecto.Changeset.put_change(:hashed_password, password_hash)
|
||||||
|
|> Ecto.Changeset.put_change(:confirmed_at, now)
|
||||||
|
|> Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp emails(run_id) do
|
||||||
|
%{
|
||||||
|
requester: "wnh-staging-e2e-#{run_id}-requester@example.invalid",
|
||||||
|
helper: "wnh-staging-e2e-#{run_id}-helper@example.invalid"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp required_env!(name) do
|
||||||
|
case System.get_env(name) do
|
||||||
|
value when is_binary(value) and value != "" -> value
|
||||||
|
_ -> Mix.raise("#{name} is required")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp uuid?(value) when is_binary(value) do
|
||||||
|
match?({:ok, _}, Ecto.UUID.cast(value))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp uuid?(_value), do: false
|
||||||
|
end
|
||||||
160
scripts/staging-e2e-run.sh
Executable file
160
scripts/staging-e2e-run.sh
Executable file
|
|
@ -0,0 +1,160 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
ENV_FILE="$ROOT/.env"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Missing $ENV_FILE." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}"
|
||||||
|
: "${PHX_HOST:?PHX_HOST is missing from .env}"
|
||||||
|
: "${PHX_SCHEME:?PHX_SCHEME is missing from .env}"
|
||||||
|
: "${PHX_URL_PORT:?PHX_URL_PORT is missing from .env}"
|
||||||
|
|
||||||
|
if [[ "$PHX_SCHEME" != https ]]; then
|
||||||
|
echo "Public staging E2E requires PHX_SCHEME=https." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
|
||||||
|
output_dir="$ROOT/output/staging-e2e/$run_id"
|
||||||
|
manifest="$output_dir/fixture.json"
|
||||||
|
base_url="${PHX_SCHEME}://${PHX_HOST}"
|
||||||
|
|
||||||
|
if [[ "$PHX_URL_PORT" != 443 ]]; then
|
||||||
|
base_url="${base_url}:${PHX_URL_PORT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
chmod 700 "$ROOT/output" "$ROOT/output/staging-e2e" "$output_dir"
|
||||||
|
|
||||||
|
fixture_password=$(openssl rand -base64 36 | tr -d '\n')
|
||||||
|
prepared=0
|
||||||
|
|
||||||
|
db_container=$(docker compose ps -q db)
|
||||||
|
|
||||||
|
if [[ -z "$db_container" ]]; then
|
||||||
|
echo "The ordinary Compose database container is not running." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
internal_network_id=$(
|
||||||
|
docker inspect "$db_container" |
|
||||||
|
jq -r '.[0].NetworkSettings.Networks | to_entries[] | select(.key | endswith("_internal")) | .value.NetworkID' |
|
||||||
|
head -n 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -z "$internal_network_id" ]]; then
|
||||||
|
echo "Could not identify the ordinary Compose internal network." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
snapshot_database() {
|
||||||
|
local destination=$1
|
||||||
|
|
||||||
|
docker compose exec -T db sh -c \
|
||||||
|
'psql --no-psqlrc --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \
|
||||||
|
>"$destination" <<'SQL'
|
||||||
|
BEGIN READ ONLY;
|
||||||
|
SELECT 'users' AS table_name, count(*) AS row_count FROM users
|
||||||
|
UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens
|
||||||
|
UNION ALL SELECT 'help_requests', count(*) FROM help_requests
|
||||||
|
UNION ALL SELECT 'messages', count(*) FROM messages
|
||||||
|
UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments
|
||||||
|
UNION ALL SELECT 'reports', count(*) FROM reports
|
||||||
|
UNION ALL SELECT 'reviews', count(*) FROM reviews
|
||||||
|
UNION ALL SELECT 'audit_events', count(*) FROM audit_events
|
||||||
|
UNION ALL SELECT 'abuse_signals', count(*) FROM abuse_signals
|
||||||
|
UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions
|
||||||
|
UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions
|
||||||
|
UNION ALL SELECT 'activities', count(*) FROM activities
|
||||||
|
UNION ALL SELECT 'activity_participants', count(*) FROM activity_participants
|
||||||
|
UNION ALL SELECT 'activity_messages', count(*) FROM activity_messages
|
||||||
|
UNION ALL SELECT 'category_proposals', count(*) FROM category_proposals
|
||||||
|
UNION ALL SELECT 'category_votes', count(*) FROM category_votes
|
||||||
|
UNION ALL SELECT 'blocks', count(*) FROM blocks
|
||||||
|
UNION ALL SELECT 'social_identities', count(*) FROM social_identities
|
||||||
|
ORDER BY table_name;
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
}
|
||||||
|
|
||||||
|
run_fixture_tool() {
|
||||||
|
local action=$1
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--network "$internal_network_id" \
|
||||||
|
--env-file "$ENV_FILE" \
|
||||||
|
--env APP_ROLE=migrate \
|
||||||
|
--env "WNH_STAGING_E2E_EXPECTED_DATABASE=$POSTGRES_DB" \
|
||||||
|
--env WNH_STAGING_E2E_CONFIRM=public-staging-e2e \
|
||||||
|
--env "WNH_STAGING_E2E_RUN_ID=$run_id" \
|
||||||
|
--env "WNH_STAGING_E2E_PASSWORD=$fixture_password" \
|
||||||
|
--env WNH_STAGING_E2E_MANIFEST_PATH=/output/fixture.json \
|
||||||
|
--volume "$output_dir:/output" \
|
||||||
|
who-need-help:staging-e2e-tools \
|
||||||
|
mix wnh.staging_e2e "$action"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
if [[ "$prepared" -eq 1 ]]; then
|
||||||
|
if ! run_fixture_tool cleanup >"$output_dir/fixture-cleanup.log" 2>&1; then
|
||||||
|
echo "Exact staging E2E cleanup failed; inspect $output_dir." >&2
|
||||||
|
status=1
|
||||||
|
else
|
||||||
|
snapshot_database "$output_dir/database-after.txt"
|
||||||
|
|
||||||
|
if ! diff -u \
|
||||||
|
"$output_dir/database-before.txt" \
|
||||||
|
"$output_dir/database-after.txt" \
|
||||||
|
>"$output_dir/database-cleanup.diff"; then
|
||||||
|
echo "Staging E2E cleanup did not restore application table counts." >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset fixture_password
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
curl --fail --silent --show-error "$base_url/healthz/ready" >"$output_dir/public-ready.txt"
|
||||||
|
snapshot_database "$output_dir/database-before.txt"
|
||||||
|
|
||||||
|
docker build --target load_tools --tag who-need-help:staging-e2e-tools . \
|
||||||
|
>"$output_dir/tools-build.log"
|
||||||
|
docker build --tag who-need-help-e2e-tests:local e2e \
|
||||||
|
>"$output_dir/browser-build.log"
|
||||||
|
|
||||||
|
run_fixture_tool prepare >"$output_dir/fixture-prepare.log"
|
||||||
|
prepared=1
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
--network host \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
--env "BASE_URL=$base_url" \
|
||||||
|
--env MAILPIT_URL=http://127.0.0.1:8027 \
|
||||||
|
--env "E2E_RUN_ID=$run_id" \
|
||||||
|
--env "E2E_FIXTURE_PASSWORD=$fixture_password" \
|
||||||
|
--env HOME=/tmp \
|
||||||
|
--volume "$output_dir:/work/output" \
|
||||||
|
who-need-help-e2e-tests:local \
|
||||||
|
npx playwright test --project=chromium tests/mutual-aid.spec.ts
|
||||||
|
|
||||||
|
echo "Public staging medicine E2E passed: $base_url"
|
||||||
|
echo "Evidence: $output_dir"
|
||||||
Loading…
Reference in New Issue
Block a user