@@ -0,0 +1,201 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The account page in a browser (joint contract §12): a person exports their data -- every installed
|
||||
// module's section in one file -- then deletes the account with the keyboard, and is signed out on
|
||||
// every device; the browser modules' stored state is gone, and the same address signs up again as
|
||||
// a new person with nothing. axe on each state the page shows.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { callRaw, makeOrganization, openPersonMenu, signIn, unique, violations } from "./support/app"
|
||||
|
||||
test("a person exports their data, then deletes the account and is signed out everywhere", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
const email = `${unique("leaver")}@example.test`
|
||||
await signIn(page, email)
|
||||
const { org } = await makeOrganization(page, "leaver")
|
||||
// A second device of the same person.
|
||||
const elsewhere = await browser.newContext()
|
||||
const other = await elsewhere.newPage()
|
||||
await signIn(other, email)
|
||||
|
||||
// What the browser modules keep for a signed-in person, as their providers store it.
|
||||
await page.evaluate((slug) => {
|
||||
sessionStorage.setItem(`conusai.guides.${slug}.offline`, "{}") // kit:guides
|
||||
sessionStorage.setItem("conusai.support.session.v1:spk_e2e", "{}") // kit:support
|
||||
sessionStorage.setItem(`unrelated.${slug}`, "kept")
|
||||
}, org)
|
||||
|
||||
// The shell links to the page from every page.
|
||||
await page.goto("/")
|
||||
await openPersonMenu(page)
|
||||
await page.getByRole("menuitem", { name: "Account", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/settings\/account$/)
|
||||
const account = page.getByRole("region", { name: "Your account", exact: true })
|
||||
await expect(account.getByRole("heading", { level: 2, name: "Your account", exact: true })).toBeVisible()
|
||||
await expect(account.getByText(email, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(/You are the only member of Org leaver-/)).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// Export: one JSON file, every section named.
|
||||
const download = page.waitForEvent("download")
|
||||
await page.getByRole("button", { name: "Export my data" }).click()
|
||||
const file = await download
|
||||
expect(file.suggestedFilename()).toMatch(/^account-export-\d{4}-\d{2}-\d{2}\.json$/)
|
||||
const exported = JSON.parse(readFileSync(await file.path(), "utf8")) as {
|
||||
format: string
|
||||
complete: boolean
|
||||
sections: Record<string, { status: string; data: unknown }>
|
||||
}
|
||||
expect(exported.format).toBe("conusai.account-export.v1")
|
||||
expect(exported.complete).toBe(true)
|
||||
expect(exported.sections.account).toMatchObject({
|
||||
status: "ok",
|
||||
data: { profile: { email }, organizations: [{ slug: org, role: "owner" }] },
|
||||
})
|
||||
// Offline (the fake platform), the services hold nothing of the person, and say so.
|
||||
expect(exported.sections.guides).toEqual({ status: "ok", data: null }) // kit:guides
|
||||
expect(exported.sections.support).toEqual({ status: "ok", data: null }) // kit:support
|
||||
await expect(page.getByRole("status").filter({ hasText: "Your export is downloaded." })).toBeVisible()
|
||||
|
||||
// The analytics erasure asks first, starts on Cancel, and gives focus back when it is cancelled.
|
||||
const erase = page.getByRole("button", { name: "Erase my analytics data" })
|
||||
await erase.click()
|
||||
const erasure = page.getByRole("alertdialog", { name: "Erase my analytics data" })
|
||||
await expect(erasure.getByText("cannot be undone")).toBeVisible()
|
||||
await expect(erasure.getByRole("button", { name: "Cancel" })).toBeFocused()
|
||||
expect(await violations(page)).toEqual([])
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(erasure).toBeHidden()
|
||||
await expect(erase).toBeFocused()
|
||||
|
||||
// A refusal stays in the dialog, which stays open; confirming again goes through.
|
||||
await page.route("**/account.analytics.request", (route) =>
|
||||
route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/problem+json",
|
||||
body: JSON.stringify({ title: "Unavailable", status: 503, code: "unavailable", detail: "no answer" }),
|
||||
}),
|
||||
)
|
||||
await erase.click()
|
||||
await erasure.getByRole("button", { name: "Yes, erase it" }).click()
|
||||
await expect(erasure.getByRole("alert")).toHaveText("That did not work: no answer")
|
||||
await page.unroute("**/account.analytics.request")
|
||||
await erasure.getByRole("button", { name: "Yes, erase it" }).click()
|
||||
await expect(erasure).toBeHidden()
|
||||
await expect(page.getByRole("status").filter({ hasText: "Your request is done." })).toBeVisible()
|
||||
|
||||
// A wrong address is refused, and nothing happens.
|
||||
const confirm = page.getByLabel("Type your e-mail address to confirm")
|
||||
await confirm.fill("somebody@example.test")
|
||||
await page.getByRole("button", { name: "Delete my account" }).click()
|
||||
await expect(
|
||||
page.getByRole("alert").filter({ hasText: "type the account's e-mail address to confirm" }),
|
||||
).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// The address, and Enter: the keyboard alone deletes the account.
|
||||
await confirm.fill(email)
|
||||
await confirm.press("Enter")
|
||||
await expect(
|
||||
page.getByRole("status").filter({ hasText: "Your account is deleted, and you are signed out." }),
|
||||
).toBeVisible()
|
||||
expect(await violations(page)).toEqual([])
|
||||
|
||||
// The browser modules' lifecycle handlers ran: their keys are gone, nobody else's.
|
||||
const left = await page.evaluate(() => Object.keys(sessionStorage).sort())
|
||||
expect(left).toEqual([`unrelated.${org}`])
|
||||
|
||||
// Signed out here, and on the other device.
|
||||
await page.goto("/settings/account")
|
||||
await expect(page).toHaveURL(/\/auth\/sign-in/)
|
||||
await other.goto("/")
|
||||
await expect(other.getByRole("heading", { level: 1, name: "Sign in", exact: true })).toBeVisible()
|
||||
// The other device's cookie, sent to the API: its session is revoked, so it is nobody.
|
||||
expect((await callRaw(other.request, "", "account.deletion.status", undefined)).status()).toBe(401)
|
||||
await elsewhere.close()
|
||||
|
||||
// The same address is a new person now, with no organization.
|
||||
await signIn(page, email)
|
||||
await page.goto("/")
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Create your organization" })).toBeVisible()
|
||||
await expect(page.getByRole("link", { name: `Org ${org}` })).toHaveCount(0)
|
||||
})
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`WCAG audits wait for finite dialog motion and still refuse bad contrast (${theme})`, async ({ page }, info) => {
|
||||
await signIn(page, `${unique("motion-audit")}@example.test`)
|
||||
await makeOrganization(page, "motion-audit")
|
||||
await page.goto("/")
|
||||
await openPersonMenu(page)
|
||||
await page.getByRole("menuitemradio", { name: theme === "light" ? "Light" : "Dark", exact: true }).click()
|
||||
await openPersonMenu(page)
|
||||
await page.getByRole("menuitem", { name: "Account", exact: true }).click()
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" })
|
||||
// Extend the real entrance to expose sampling during motion deterministically.
|
||||
// Keep unrelated continuous motion running: an audit must not wait forever or disable it.
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--kit-motion-standard", "2000ms")
|
||||
const continuous = document.body.animate([{ opacity: 1 }, { opacity: 1 }], {
|
||||
duration: 1000,
|
||||
iterations: Infinity,
|
||||
})
|
||||
continuous.id = "audit-continuous"
|
||||
})
|
||||
const erase = page.getByRole("button", { name: "Erase my analytics data", exact: true })
|
||||
await erase.click()
|
||||
const dialog = page.getByRole("alertdialog", { name: "Erase my analytics data", exact: true })
|
||||
expect(
|
||||
await dialog.evaluate((el) => el.getAnimations().some((animation) => animation.playState === "running")),
|
||||
).toBe(true)
|
||||
expect(await violations(page)).toEqual([])
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
document
|
||||
.getAnimations()
|
||||
.some((animation) => animation.id === "audit-continuous" && animation.playState === "running"),
|
||||
),
|
||||
).toBe(true)
|
||||
const button = dialog.getByRole("button", { name: "Yes, erase it", exact: true })
|
||||
const settled = await button.evaluate((el) => {
|
||||
const style = getComputedStyle(el)
|
||||
const dialog = el.closest('[role="alertdialog"]')
|
||||
if (!dialog) throw new Error("The confirmation dialog is missing")
|
||||
const surface = getComputedStyle(dialog)
|
||||
return {
|
||||
color: style.color,
|
||||
background: style.backgroundColor,
|
||||
opacity: surface.opacity,
|
||||
fontSize: style.fontSize,
|
||||
fontWeight: style.fontWeight,
|
||||
}
|
||||
})
|
||||
await info.attach("settled-dialog-style", {
|
||||
body: Buffer.from(JSON.stringify({ theme, ...settled }, null, 2)),
|
||||
contentType: "application/json",
|
||||
})
|
||||
await page.screenshot({ path: info.outputPath(`motion-audit-${theme}.png`) })
|
||||
// A genuine steady-state violation must still be reported after synchronization.
|
||||
await page.evaluate(() => {
|
||||
for (const animation of document.getAnimations()) if (animation.id === "audit-continuous") animation.cancel()
|
||||
})
|
||||
// Keep the known low-opacity rendering static: settling motion must not hide a real violation.
|
||||
await dialog.evaluate((el) => {
|
||||
el.style.opacity = "0.4"
|
||||
})
|
||||
const refused = await violations(page)
|
||||
await info.attach("deliberate-contrast-refusal", {
|
||||
body: Buffer.from(JSON.stringify(refused)),
|
||||
contentType: "application/json",
|
||||
})
|
||||
expect(refused.some((issue) => issue.startsWith("color-contrast:"))).toBe(true)
|
||||
await dialog.evaluate((el) => {
|
||||
el.style.removeProperty("opacity")
|
||||
})
|
||||
expect(await violations(page)).toEqual([])
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(erase).toBeFocused()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user