218 lines
11 KiB
TypeScript
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()
|
|
})
|
|
})
|