Files

218 lines
11 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// kit:guides -- the guides module's live suite; `kit template:prune --without guides` removes this folder.
//
// The notes module's code survey (`notes.feedback`, ADR-046) end to end on a running platform:
//
// 1. the code-guides bundle the image ships (`conusai.guides.code.v1`, ADR-045) carries the survey;
// with the deploy job's publish credential it is published here as the deploy step does,
// otherwise the deployment's own bundle publication is relied on;
// 2. a signed-in member answers it from the notes page ("Give feedback"), with the keyboard where
// it matters, attaches a small PNG to the file question (through the app's own server), and
// sees the thank-you;
// 3. the console's results page counts the response and lists the file by its name, and its CSV
// export holds the answers;
// 4. the member deletes their account -- the guides module's account-deletion hook calls
// `eraseEndUser` -- and the answers and the file are gone from the results and the export.
//
// What it needs (it skips, naming what is missing, without them):
// E2E_GUIDES_APP_URL the deployed app, with the dev sign-in adapter (`/auth/dev-login`) of a
// stage environment, so every run signs in a new person
// E2E_GUIDES_CONSOLE_URL the console origin
// E2E_GUIDES_CONSOLE_STATE a Playwright storage-state file of a console member holding guides:read
// on the app's project
// E2E_GUIDES_PROJECT the project's slug in the console
// and, to publish the bundle from here: CONUSAI_API_URL, CONUSAI_GUIDES_PUBLISH_TOKEN and
// CONUSAI_GUIDES_DEPLOYMENT, as the platform hands them to a deploy job. The project's guide settings
// must allow the `survey` kind.
import { readFileSync } from "node:fs"
import { appModules } from "@app/modules"
import { buildRegistry } from "@conusai-kit/core"
import { guidesBundleOf, publishGuidesAtDeploy } from "@conusai-kit/guides/deploy"
import { type Browser, expect, type Page, test } from "@playwright/test"
import { call, signIn, unique } from "../support/app"
const KEY = "notes.feedback"
const TITLE = "How are notes working for you?"
const env = {
app: process.env.E2E_GUIDES_APP_URL,
console: process.env.E2E_GUIDES_CONSOLE_URL,
consoleState: process.env.E2E_GUIDES_CONSOLE_STATE,
project: process.env.E2E_GUIDES_PROJECT,
}
const missing = Object.entries({
E2E_GUIDES_APP_URL: env.app,
E2E_GUIDES_CONSOLE_URL: env.console,
E2E_GUIDES_CONSOLE_STATE: env.consoleState,
E2E_GUIDES_PROJECT: env.project,
})
.filter(([, value]) => !value)
.map(([name]) => name)
/** A 1 x 1 PNG: the smallest image the platform's content check accepts as one. */
const PNG = Buffer.from(
"89504e470d0a1a0a0000000d4948445200000001000000010806000000" +
"1f15c4890000000d49444154789c6360000002000154a24f5d0000000049454e44ae426082",
"hex",
)
/** Letters only, so the platform's masking of free text leaves the marker as it is. */
const marker = (): string =>
Array.from({ length: 10 }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("")
test("the image's code-guides bundle carries the notes survey in the unchanged format", () => {
const bundle = guidesBundleOf(buildRegistry([...appModules]))
expect(bundle.format).toBe("conusai.guides.code.v1")
const survey = bundle.guides.find((guide) => guide.key === KEY)
expect(survey?.content).toMatchObject({ kind: "survey", trigger: { manual: true }, frequency: "once" })
expect(Object.keys(survey ?? {})).toEqual(["key", "content"])
})
test.describe("the notes survey on a running platform", () => {
test.skip(missing.length > 0, `needs a running platform: set ${missing.join(", ")}`)
/** The console page of the survey's results, with its guide id from the guides list. */
async function openResults(browser: Browser): Promise<{ page: Page; url: string }> {
const context = await browser.newContext({ storageState: env.consoleState ?? "", acceptDownloads: true })
const page = await context.newPage()
await page.goto(`${env.console}/projects/${env.project}/guides`)
await page
.getByRole("link", { name: new RegExp(`${KEY.replace(".", "\\.")}|${TITLE.replace("?", "\\?")}`) })
.first()
.click()
await expect(page).toHaveURL(/\/guides\/[^/]+$/)
const url = `${page.url().replace(/\/$/, "")}/results`
await page.goto(url)
await expect(page.getByRole("heading", { name: "Results" })).toBeVisible()
return { page, url }
}
async function respondents(page: Page, url: string): Promise<number> {
await page.goto(url)
const figure = page.getByRole("figure", { name: "Respondents" })
await expect(figure).toBeVisible()
const value = /(\d[\d,.\s]*)/.exec((await figure.innerText()).replace("Respondents", ""))?.[1] ?? "NaN"
return Number(value.replace(/[,.\s]/g, ""))
}
async function exportCsv(page: Page, url: string): Promise<string> {
await page.goto(url)
const download = page.waitForEvent("download")
await page.getByRole("link", { name: "Export CSV" }).click()
return readFileSync((await (await download).path()) ?? "", "utf8")
}
test("a member answers it, the console counts and exports the response, and erasure removes it", async ({
page,
browser,
}) => {
// 1. Publication: the deploy step, when this run holds the deploy job's credential.
if (process.env.CONUSAI_GUIDES_PUBLISH_TOKEN && process.env.CONUSAI_GUIDES_DEPLOYMENT) {
const outcome = await publishGuidesAtDeploy({ registry: buildRegistry([...appModules]), env: process.env })
expect(outcome.status, JSON.stringify(outcome)).toBe("published")
if (outcome.status === "published") {
expect(outcome.guides.find((guide) => guide.key === KEY)?.status).toBe("published")
}
} else {
test.info().annotations.push({
type: "publication",
description: "no publish credential: relying on the deployment's guides.bundle publication (ADR-045)",
})
}
const results = await openResults(browser)
const before = await respondents(results.page, results.url)
// 2. A new member answers the survey from the notes page.
const email = `${unique("surveyed")}@example.test`
await signIn(page, email)
const org = unique("survey")
await call(page, "tenancy.org.create", { slug: org, name: `Org ${org}` })
const workspace = (
await call<{ slug: string }>(page, "tenancy.workspace.create", {
org,
slug: "main",
name: "Main",
visibility: "org",
})
).slug
const project = (
await call<{ slug: string }>(page, "tenancy.project.create", {
org,
workspace,
slug: "notes",
name: "Notes",
moduleKey: "notes",
})
).slug
await page.goto(`/${org}/${workspace}/${project}/notes`)
await page.getByRole("button", { name: "Give feedback" }).click()
const dialog = page.getByRole("dialog", { name: TITLE })
await expect(dialog).toBeVisible()
await expect(dialog.getByText("Question 1 of 4")).toBeVisible()
await dialog.getByRole("radio", { name: "4" }).check()
await page.keyboard.press("Enter")
await expect(dialog.getByText("Question 2 of 4")).toBeVisible()
await dialog.getByRole("radio", { name: "Meeting notes" }).check()
await dialog.getByRole("button", { name: "Next" }).click()
const text = `Faster search ${marker()}`
const field = dialog.getByRole("textbox", { name: "What would make notes better for you?" })
await expect(field).toHaveAccessibleDescription(/Do not enter personal data/)
await field.fill(text)
// The answer is never kept in the browser's storage.
expect(await page.evaluate(() => JSON.stringify({ ...sessionStorage, ...localStorage }))).not.toContain(text)
await dialog.getByRole("button", { name: "Next" }).click()
// The file question: a small PNG, uploaded through the app's own server as it is chosen.
await expect(dialog.getByText("Question 4 of 4")).toBeVisible()
const fileName = `screenshot-${marker()}.png`
const input = dialog.getByLabel("Choose files")
await expect(input).toHaveAccessibleDescription(/Files must not contain personal data such as identity documents\./)
await input.setInputFiles({ name: fileName, mimeType: "image/png", buffer: PNG })
await expect(dialog.getByRole("status")).toHaveText(`${fileName} attached.`)
await expect(dialog.getByRole("list", { name: "Attached files" })).toContainText(fileName)
expect(await page.evaluate(() => JSON.stringify({ ...sessionStorage, ...localStorage }))).not.toContain(fileName)
await dialog.getByRole("button", { name: "Submit" }).click()
await expect(dialog.getByRole("status")).toHaveText("Thank you. We read every answer.")
await dialog.getByRole("button", { name: "Close" }).click()
await expect(page.getByRole("dialog", { name: TITLE })).toHaveCount(0)
// 3. The console counts the response, lists the file by its name, and exports the answers.
await expect.poll(() => respondents(results.page, results.url), { timeout: 60_000 }).toBe(before + 1)
await results.page.goto(results.url)
await expect(results.page.getByRole("link", { name: fileName })).toBeVisible()
const csv = await exportCsv(results.page, results.url)
const header = csv.split(/\r?\n/)[0]
expect(header).toBe("environment,version,respondent,question_id,question,answer_ids,answer_labels,text,answered_at")
const mine = csv.split(/\r?\n/).find((line) => line.includes(text))
expect(mine, "the free-text answer is in the export").toBeTruthy()
const respondent = mine?.split(",")[2] ?? ""
const rows = csv.split(/\r?\n/).filter((line) => line.split(",")[2] === respondent)
// The file answer's row, if the export has one, is the console's to word; the others are pinned.
expect(
rows
.map((line) => line.split(",")[3])
.filter((id) => id !== "screenshot")
.sort(),
).toEqual(["ease", "improve", "use"])
expect(rows.find((line) => line.split(",")[3] === "ease")).toContain(",4,")
expect(rows.find((line) => line.split(",")[3] === "use")).toContain("meetings")
// 4. The member deletes the account: the guides module erases the end user on the platform.
await page.goto("/settings/account")
await page.getByLabel("Type your e-mail address to confirm").fill(email)
await page.getByRole("button", { name: "Delete my account" }).click()
await expect(
page.getByRole("status").filter({ hasText: "Your account is deleted, and you are signed out." }),
).toBeVisible()
await expect.poll(() => respondents(results.page, results.url), { timeout: 60_000 }).toBe(before)
await results.page.goto(results.url)
await expect(results.page.getByRole("link", { name: fileName })).toHaveCount(0)
const after = await exportCsv(results.page, results.url)
expect(after).not.toContain(text)
expect(after).not.toContain(fileName)
expect(after.split(/\r?\n/).some((line) => line.split(",")[2] === respondent)).toBe(false)
await results.page.context().close()
})
})