Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 280 additions & 0 deletions dembrane/frontend/e2e/digest-harness.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
import { expect, type Page, test } from "@playwright/test";
import {
DIGEST_RESULT_EN,
DIGEST_RESULT_NL,
} from "../src/components/project/digest/digest.fixture";
import {
makeDocx,
makePdf,
makePptx,
} from "../src/components/project/digest/extract/fixtures";

// ECHO-1055: "Start from your documents" on the template picker. Real generated PDF, DOCX
// and PPTX files go into the drop zone; the browser reads them, the digest endpoint is
// answered here. The entry row, the reading states, the review with every section, a
// section's provenance, an edited section, the failure fallback and the scanned-PDF
// refusal, at desktop and phone width, in English and Dutch. A network capture checks
// that only text leaves the browser. E2E_SHOTS=<dir> also saves screenshots.
const shots = process.env.E2E_SHOTS;
const sizes = [
{ height: 900, name: "desktop", width: 1280 },
{ height: 844, name: "phone", width: 390 },
];

const INVITATION = makePdf([
[
"Uitnodiging bewonersavond Kanaalzone-Oost",
"Gemeente Veldhoven-Noord en woningcorporatie Thuisbasis nodigen je uit.",
"Donderdag 4 december, 19.30 tot 21.30 uur",
"Locatie: Wijkcentrum De Schakel, Sluisweg 12",
],
["Aanmelden bij Ilse Verbeek, i.verbeek@thuisbasis.example of 06-41234567."],
]);
const AGENDA = makeDocx([
"Agenda bewonersavond Kanaalzone-Oost",
"19.30 Welkom door wethouder Ramon Dijkhuis",
"19.45 Ronde 1: Wat gaat goed in de Kanaalzone-Oost?",
"20.35 Ronde 2: Wat moet anders aan de groenstrook langs de Sluisweg?",
]);
const SLIDES = makePptx([
{
lines: [
"Bewonersavond Kanaalzone-Oost",
"Bewoners praten aan tafels in twee rondes",
],
},
{
lines: ["Ronde 1", "Wat gaat goed?"],
notes: ["Laat iedereen kort aan het woord."],
},
]);
const SCAN = makePdf([null, null]);

const files = {
agenda: {
buffer: Buffer.from(AGENDA),
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
name: "agenda.docx",
},
invitation: {
buffer: Buffer.from(INVITATION),
mimeType: "application/pdf",
name: "uitnodiging-bewonersavond.pdf",
},
scan: {
buffer: Buffer.from(SCAN),
mimeType: "application/pdf",
name: "scan.pdf",
},
slides: {
buffer: Buffer.from(SLIDES),
mimeType:
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
name: "programma.pptx",
},
};

type Answer = "result" | "fail" | "hold";

async function answerApi(page: Page, nl: boolean, answer: () => Answer) {
const sent: { url: string; body: string; type: string }[] = [];
page.on("request", (r) => {
if (r.method() !== "GET")
sent.push({
body: r.postData() ?? "",
type: r.headers()["content-type"] ?? "",
url: r.url(),
});
});
let release: (() => void) | null = null;
await page.route("**/v2/workspaces/w1/free-allowance**", (route) =>
route.fulfill({ json: { free: false } }),
);
await page.route(
"**/v2/workspaces/w1/project-setup/digest",
async (route) => {
const a = answer();
if (a === "hold")
await new Promise<void>((r) => {
release = r;
});
if (a === "fail")
return route.fulfill({
json: { code: "project.setup_suggestions_unavailable" },
status: 503,
});
return route.fulfill({ json: nl ? DIGEST_RESULT_NL : DIGEST_RESULT_EN });
},
);
await page.route("**/v2/workspaces/w1/projects", (route) =>
route.fulfill({ json: { id: "p9", name: "x", workspace_id: "w1" } }),
);
return { release: () => release?.(), sent };
}

for (const size of sizes)
for (const lang of ["en-US", "nl-NL"])
test(`digest harness, ${size.name}, ${lang}`, async ({ page }) => {
test.setTimeout(120_000);
const nl = lang === "nl-NL";
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.setViewportSize({ height: size.height, width: size.width });
await page.addInitScript((l) => {
localStorage.setItem("dembrane-language", l);
}, lang);
let answer: Answer = "hold";
const api = await answerApi(page, nl, () => answer);
const shot = async (name: string, full = true) => {
if (shots)
await page.screenshot({
fullPage: full,
path: `${shots}/${size.name}-${lang}-${name}.png`,
});
};
const noSideScroll = async () =>
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);

await page.goto(
`/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new`,
);
const entry = page.getByTestId("project-digest-entry");
await expect(entry).toBeVisible();
await expect(page.getByTestId("template-picker")).toBeVisible();
await expect(page.getByTestId("project-digest-privacy")).toBeVisible();
await shot("01-entry", false);
await noSideScroll();

// The scanned PDF is refused on this device; nothing is sent.
const input = page.locator(
"[data-testid=project-digest-dropzone] input[type=file]",
);
await input.setInputFiles([files.scan]);
await expect(
page.getByTestId("project-digest-file-0-reason"),
).toContainText(nl ? "scan" : "scan");
await expect(page.getByTestId("template-picker")).toBeVisible();
await shot("02-scanned-refused", false);
expect(api.sent.filter((s) => s.url.includes("digest"))).toHaveLength(0);

// Three real files: read here, then "Reading your material" while held.
await input.setInputFiles([files.invitation, files.agenda, files.slides]);
await expect(
page.getByTestId("project-digest-progress-status"),
).toContainText(
nl ? "Je materiaal wordt gelezen" : "Reading your material",
);
await expect(page.getByTestId("template-picker")).toBeHidden();
await shot("03-reading", false);
await noSideScroll();
answer = "result";
api.release();

const review = page.getByTestId("project-digest-review");
await expect(review).toBeVisible();
for (const id of [
"name",
"goal",
"context",
"key-terms",
"template",
"tags",
"verify",
"name-label",
"explore",
"host-steps",
])
await expect(
page.getByTestId(`project-digest-section-${id}`),
).toBeVisible();
await page.waitForTimeout(300);
await shot("04-review");
await noSideScroll();

// Provenance: the source on the button, the words on focus.
const from = page.getByTestId("project-digest-provenance-goal");
await from.scrollIntoViewIfNeeded();
await from.focus();
await expect(page.getByRole("tooltip")).toContainText("Samen met jou");
await page.waitForTimeout(200);
await shot("05-provenance", false);

// Edited: the goal rewritten, the Explore question switched off, another template.
await page
.getByTestId("project-digest-goal")
.fill(
nl
? "Weten wat bewoners willen aan de Sluisweg."
: "Learn what residents want at the Sluisweg.",
);
await page
.getByTestId("project-digest-section-explore-switch")
.evaluate((el) => (el as HTMLInputElement).click());
await page.getByTestId("project-digest-runner-up-world_cafe").click();
await page
.getByTestId("project-digest-section-goal")
.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
await shot("06-edited");

// The network: only text and names went out, never a file's bytes.
const digestCalls = api.sent.filter((s) =>
s.url.includes("project-setup/digest"),
);
expect(digestCalls).toHaveLength(1);
for (const s of api.sent) {
expect(s.type).not.toContain("multipart");
expect(s.body).not.toContain("%PDF");
expect(s.body).not.toContain("PK\u0003\u0004");
}
const body = JSON.parse(digestCalls[0]?.body ?? "{}");
expect(body.sources.map((x: { name: string }) => x.name)).toEqual([
"uitnodiging-bewonersavond.pdf",
"agenda.docx",
"programma.pptx",
]);
expect(Object.keys(body.sources[0]).sort()).toEqual([
"kind",
"name",
"text",
]);
expect(body.sources[0].text).toContain("Wijkcentrum De Schakel");
expect(body.sources[2].text).toContain("Speaker notes: Laat iedereen");

// Create sends the template and what is on, in one request.
await page.getByTestId("project-digest-create").click();
await expect(page.getByTestId("harness-landed")).toBeAttached();
const create = api.sent.find((s) =>
s.url.endsWith("/v2/workspaces/w1/projects"),
);
const created = JSON.parse(create?.body ?? "{}");
expect(created.template_key).toBe("world_cafe");
expect(created.digest.goal).toContain("Sluisweg");
expect(created.digest.explore_prompt).toBeUndefined();
expect(created.digest.key_terms).toHaveLength(5);

// The failure fallback: the picker with the text in the context.
answer = "fail";
await page.goto(
`/e2e/templates-harness.html?path=/${lang}/w/w1/projects/new`,
);
await page
.locator("[data-testid=project-digest-dropzone] input[type=file]")
.setInputFiles([files.agenda]);
await expect(page.getByTestId("project-digest-fallback")).toBeVisible();
await expect(page.getByTestId("template-picker")).toBeVisible();
await shot("07-fallback", false);
await page.getByTestId("template-card-resident_evening").click();
await page.getByTestId("create-project-template-continue").click();
await expect(
page.getByLabel(nl ? "Projectcontext" : "Project context"),
).toHaveValue(/Ronde 1: Wat gaat goed/);
await shot("08-fallback-context");

expect(errors).toEqual([]);
});
7 changes: 7 additions & 0 deletions dembrane/frontend/lingui.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const config: LinguiConfig = {
"<rootDir>/src/features/accounts/**",
"<rootDir>/src/lib/errors/messages/**",
"<rootDir>/src/components/project/templates/**",
"<rootDir>/src/components/project/digest/**",
],
include: ["src"],
path: "<rootDir>/src/locales/{locale}",
Expand All @@ -26,6 +27,12 @@ const config: LinguiConfig = {
include: ["<rootDir>/src/components/project/templates"],
path: "<rootDir>/src/components/project/templates/locales/{locale}",
},
{
// The context digest's own catalog (ECHO-1055): the drop zone, the review and its
// messages, loaded when the drop zone first shows (src/components/project/digest/i18n.ts).
include: ["<rootDir>/src/components/project/digest"],
path: "<rootDir>/src/components/project/digest/locales/{locale}",
},
{
// The friendly message for every error code, loaded with the first error a
// screen shows (src/lib/errors/present.ts), never in the first load.
Expand Down
1 change: 1 addition & 0 deletions dembrane/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"diff": "^8.0.2",
"fflate": "0.4.8",
"js-cookie": "^3.0.8",
"lodash": "^4.18.1",
"lucide-react": "^0.468.0",
Expand Down
3 changes: 3 additions & 0 deletions dembrane/frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 72 additions & 0 deletions dembrane/frontend/src/components/dropzone/Dropzone.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeAll, expect, it, vi } from "vitest";
import {
Providers,
setUpDom,
} from "@/components/project/setup-suggestions/testing";
import { CommonDropzone } from "./Dropzone";
import { AUDIO_ACCEPT } from "./UploadConversationDropzone";

beforeAll(setUpDom);
afterEach(cleanup);

const input = (container: HTMLElement) =>
container.querySelector("input[type=file]") as HTMLInputElement;

const renderZone = (onDrop = vi.fn(), onReject = vi.fn()) => {
const view = render(
<Providers>
<CommonDropzone
accept={AUDIO_ACCEPT}
onDrop={onDrop}
onReject={onReject}
acceptContent={<span>drop it</span>}
>
<span>pick audio</span>
</CommonDropzone>
</Providers>,
);
return { ...view, onDrop, onReject };
};

it("filters with the accept setting and never renders it as text", () => {
const { container } = renderZone();
expect(container.textContent).not.toContain("audio/mpeg");
const accept = input(container).getAttribute("accept") ?? "";
for (const part of ["audio/mpeg", "video/mp4", ".mp3", ".qta", ".m4a"])
expect(accept).toContain(part);
expect(screen.getByText("pick audio")).toBeTruthy();
});

it("takes an audio file by its type or its extension", async () => {
const { container, onDrop, onReject } = renderZone();
const byType = new File(["x"], "recording", { type: "audio/mpeg" });
const byExtension = new File(["x"], "memo.qta", { type: "" });
fireEvent.change(input(container), {
target: { files: [byType, byExtension] },
});
await waitFor(() => expect(onDrop).toHaveBeenCalled());
expect(onDrop.mock.calls[0][0].map((f: File) => f.name)).toEqual([
"recording",
"memo.qta",
]);
expect(onReject).not.toHaveBeenCalled();
});

it("rejects anything else itself, as an invalid type", async () => {
const { container, onDrop, onReject } = renderZone();
const pdf = new File(["%PDF"], "agenda.pdf", { type: "application/pdf" });
fireEvent.change(input(container), { target: { files: [pdf] } });
await waitFor(() => expect(onReject).toHaveBeenCalled());
const [rejection] = onReject.mock.calls[0][0];
expect(rejection.file.name).toBe("agenda.pdf");
expect(rejection.errors[0].code).toBe("file-invalid-type");
expect(onDrop.mock.calls.every((c) => c[0].length === 0)).toBe(true);
});
Loading
Loading