215 lines
7.6 KiB
TypeScript
215 lines
7.6 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import {
|
|
clickUntilVisible,
|
|
gotoLiveView,
|
|
markerKinds,
|
|
projectEmail,
|
|
projectText,
|
|
registerAndConfirm,
|
|
selectOptionContaining,
|
|
waitForLiveViewConnected,
|
|
} from "./helpers";
|
|
|
|
async function positionCount(page: import("@playwright/test").Page): Promise<number> {
|
|
const raw = await page.locator("#e2e-runtime-node").getAttribute("data-position-count");
|
|
return Number(raw ?? "-1");
|
|
}
|
|
|
|
async function runtimeSignature(
|
|
page: import("@playwright/test").Page,
|
|
): Promise<{ node: string; bootID: string } | null> {
|
|
const runtime = page.locator("#e2e-runtime-node");
|
|
const [node, bootID] = await Promise.all([
|
|
runtime.getAttribute("data-node"),
|
|
runtime.getAttribute("data-boot-id"),
|
|
]);
|
|
|
|
return node && bootID ? { node, bootID } : null;
|
|
}
|
|
|
|
test("active chat and browser tracking recover after the serving BEAM node restarts", async ({
|
|
browser,
|
|
request,
|
|
}, testInfo) => {
|
|
const requester = await registerAndConfirm(
|
|
browser,
|
|
request,
|
|
projectEmail("failover-requester", testInfo.project.name),
|
|
"Failover Requester",
|
|
);
|
|
const helper = await registerAndConfirm(
|
|
browser,
|
|
request,
|
|
projectEmail("failover-helper", testInfo.project.name),
|
|
"Failover Helper",
|
|
);
|
|
const requestTitle = projectText("Failover medicine pickup", testInfo.project.name);
|
|
|
|
await gotoLiveView(requester.page, "/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(requestTitle);
|
|
await requester.page
|
|
.getByLabel("What help do you need?")
|
|
.fill("A run-scoped reserved medicine pickup for failover verification.");
|
|
await requester.page.getByLabel("Urgency").selectOption("now");
|
|
await requester.page
|
|
.getByLabel("Request expires (UTC)")
|
|
.fill(new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString().slice(0, 16));
|
|
await requester.page.getByLabel("Approximate area").fill("Failover district");
|
|
await requester.page.getByLabel("Location visibility").selectOption("approximate_public");
|
|
await requester.page.getByLabel("Latitude").fill("50.4501");
|
|
await requester.page.getByLabel("Longitude").fill("30.5234");
|
|
await requester.page.locator("#request-form input[type=checkbox]").check();
|
|
await requester.page.getByRole("button", { name: "Publish request" }).click();
|
|
await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);
|
|
|
|
const requestURL = requester.page.url();
|
|
const origin = new URL(process.env.BASE_URL!).origin;
|
|
await helper.context.grantPermissions(["geolocation"], { origin });
|
|
await helper.context.setGeolocation({
|
|
latitude: 50.4504,
|
|
longitude: 30.5237,
|
|
accuracy: 5,
|
|
});
|
|
await gotoLiveView(helper.page, requestURL);
|
|
await clickUntilVisible(
|
|
helper.page.getByRole("button", { name: "I can help" }),
|
|
helper.page.getByRole("heading", { name: "Private match chat" }),
|
|
);
|
|
await expect(requester.page.getByRole("heading", { name: "Private match chat" })).toBeVisible();
|
|
|
|
const browserPosition = await helper.page.evaluate(
|
|
() =>
|
|
new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
|
|
navigator.geolocation.getCurrentPosition(
|
|
({ coords }) =>
|
|
resolve({
|
|
latitude: coords.latitude,
|
|
longitude: coords.longitude,
|
|
}),
|
|
({ code, message }) => reject(new Error(`geolocation ${code}: ${message}`)),
|
|
);
|
|
}),
|
|
);
|
|
expect(browserPosition.latitude).toBeCloseTo(50.4504, 4);
|
|
expect(browserPosition.longitude).toBeCloseTo(30.5237, 4);
|
|
|
|
await helper.page.getByRole("button", { name: "Share location" }).click();
|
|
await expect(helper.page.getByRole("button", { name: "Stop and delete position" })).toBeVisible();
|
|
await expect.poll(() => positionCount(helper.page)).toBe(1);
|
|
await expect.poll(() => positionCount(requester.page)).toBe(1);
|
|
await expect.poll(() => markerKinds(requester.page)).toContain("Shared live location");
|
|
|
|
await helper.page
|
|
.getByPlaceholder("Write a safe coordination message…")
|
|
.fill("Message before the serving node restart.");
|
|
await helper.page.getByRole("button", { name: "Send", exact: true }).click();
|
|
await expect(requester.page.getByText("Message before the serving node restart.")).toBeVisible();
|
|
|
|
await helper.page.evaluate(() => {
|
|
const state = { sawDisconnected: false };
|
|
|
|
(
|
|
window as typeof window & {
|
|
__wnhFailoverState?: { sawDisconnected: boolean };
|
|
}
|
|
).__wnhFailoverState = state;
|
|
|
|
window.addEventListener("phx:page-loading-start", (event) => {
|
|
const detail = (event as CustomEvent<{ kind?: string }>).detail;
|
|
|
|
if (detail?.kind === "error") {
|
|
state.sawDisconnected = true;
|
|
}
|
|
});
|
|
});
|
|
|
|
const originalRuntime = await runtimeSignature(helper.page);
|
|
expect(originalRuntime).not.toBeNull();
|
|
|
|
const crash = await request.post("/__e2e__/crash-node", {
|
|
data: {
|
|
confirmation: "crash-exact-e2e-node",
|
|
node: originalRuntime!.node,
|
|
},
|
|
});
|
|
expect(crash.status()).toBe(202);
|
|
expect(await crash.json()).toMatchObject({
|
|
status: "scheduled",
|
|
node: originalRuntime!.node,
|
|
});
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
helper.page.evaluate(
|
|
() =>
|
|
(
|
|
window as typeof window & {
|
|
__wnhFailoverState?: { sawDisconnected: boolean };
|
|
}
|
|
).__wnhFailoverState?.sawDisconnected ?? false,
|
|
),
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBe(true);
|
|
|
|
await waitForLiveViewConnected(helper.page);
|
|
let recoveredRuntime: Awaited<ReturnType<typeof runtimeSignature>> = null;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
recoveredRuntime = await runtimeSignature(helper.page);
|
|
return recoveredRuntime
|
|
? `${recoveredRuntime.node}/${recoveredRuntime.bootID}`
|
|
: null;
|
|
},
|
|
{ timeout: 30_000 },
|
|
)
|
|
.not.toBe(`${originalRuntime!.node}/${originalRuntime!.bootID}`);
|
|
|
|
await expect(helper.page.getByRole("button", { name: "Stop and delete position" })).toBeVisible();
|
|
await expect.poll(() => positionCount(helper.page), { timeout: 30_000 }).toBe(1);
|
|
await expect.poll(() => positionCount(requester.page), { timeout: 30_000 }).toBe(1);
|
|
await expect.poll(() => markerKinds(requester.page), { timeout: 30_000 }).toContain(
|
|
"Shared live location",
|
|
);
|
|
|
|
await helper.page
|
|
.getByPlaceholder("Write a safe coordination message…")
|
|
.fill("Message after LiveView recovered on the available replicas.");
|
|
await helper.page.getByRole("button", { name: "Send", exact: true }).click();
|
|
await expect(
|
|
requester.page.getByText("Message after LiveView recovered on the available replicas."),
|
|
).toBeVisible();
|
|
|
|
await helper.page.getByRole("button", { name: "Stop and delete position" }).click();
|
|
await expect(helper.page.getByRole("button", { name: "Share location" })).toBeVisible();
|
|
await expect.poll(() => positionCount(helper.page)).toBe(0);
|
|
await expect.poll(() => positionCount(requester.page)).toBe(0);
|
|
await expect.poll(() => markerKinds(requester.page)).not.toContain("Shared live location");
|
|
|
|
await testInfo.attach("active-failover-evidence", {
|
|
contentType: "application/json",
|
|
body: Buffer.from(
|
|
JSON.stringify(
|
|
{
|
|
browser: testInfo.project.name,
|
|
original_runtime: originalRuntime,
|
|
recovered_runtime: recoveredRuntime,
|
|
saw_liveview_disconnect: true,
|
|
tracking_recovered: true,
|
|
chat_recovered: true,
|
|
tracking_position_deleted: true,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
),
|
|
});
|
|
|
|
await requester.context.close();
|
|
await helper.context.close();
|
|
});
|