// 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 { 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 { 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() }) })