1import type { Page } from "@playwright/test";
2import { expect, test as base, uid, usePersona } from "../../helpers/fixtures";
3import { raiseTicket } from "../../helpers/factories";
4import { PASSWORD, personas } from "../../helpers/personas";
5import { createArticle, createCategory } from "../../helpers/portal";
6import { snapshotSettings } from "../../helpers/settings";
7import { siteHeader } from "../../helpers/site";
8
9// Title ids (R1, H9, K2, ...) follow the portal walkthrough checklist.
10
11const KB_SETTINGS = ["public_knowledge_base", "allow_anonymous_article_voting", "banner_preset"];
12const QUICK_LINKS = "Knowledge Base Quick Links";
13const GUEST = { storageState: { cookies: [], origins: [] } };
14const PHONE = { width: 390, height: 844 };
15
16const test = base.extend<{ kb: (values: Record<string, any>) => Promise<void> }>({
17 kb: async ({ api }, use) => {
18 const restore = await snapshotSettings(api, KB_SETTINGS);
19 await use((values) => api.update("OD Settings", "OD Settings", values));
20 await restore();
21 },
22});
23
24async function seedAudiences(api) {
25 const category = await createCategory(api);
26 const make = (visibility: string, status = "Published") =>
27 createArticle(api, category.name, { title: `${visibility} ${status} ${uid()}`, visibility, status });
28 return {
29 category,
30 public: await make("Public"),
31 customers: await make("Customers"),
32 agents: await make("Private"),
33 draft: await make("Public", "Draft"),
34 };
35}
36
37function accountMenu(page: Page) {
38 return page.getByRole("button", { name: "Opendesk", exact: true });
39}
40
41test.describe("routing", () => {
42 test.describe("guest, private knowledge base", () => {
43 test.use(GUEST);
44
45 test("R1 sends a guest to login and back", async ({ page, kb }) => {
46 await kb({ public_knowledge_base: 0 });
47 await page.goto("/help/categories?a=1");
48 await expect(page).toHaveURL(/\/login\?redirect-to=%2Fhelp%2Fcategories%3Fa%3D1/);
49 await page.locator("#login_email").fill(personas.customer.email);
50 await page.locator("#login_password").fill(PASSWORD);
51 await page.getByRole("button", { name: "Continue", exact: true }).click();
52 await expect(page).toHaveURL(/\/help\/categories\?a=1$/);
53 });
54 });
55
56 test.describe("guest, public knowledge base", () => {
57 test.use(GUEST);
58
59 test("R2 knowledge base pages render signed out", async ({ page, api, kb }) => {
60 await kb({ public_knowledge_base: 1 });
61 const category = await createCategory(api);
62 const article = await createArticle(api, category.name);
63 await page.goto("/help");
64 await expect(page.getByRole("heading", { name: "Here's what might help" })).toBeVisible();
65 await expect(page.getByRole("button", { name: "Log in" })).toBeVisible();
66 await page.goto("/help/categories");
67 await expect(page.getByRole("heading", { name: "All categories" })).toBeVisible();
68 await page.goto(`/help/category/${category.name}`);
69 await expect(page.getByRole("heading", { name: category.category_name, level: 1 })).toBeVisible();
70 await page.goto(`/help/articles/${article.name}`);
71 await expect(page.getByRole("heading", { name: article.title, level: 1 })).toBeVisible();
72 });
73
74 test("R3 ticket pages send a guest to login with the query kept", async ({ page, kb, apiAs }) => {
75 await kb({ public_knowledge_base: 1 });
76 const ticket = await raiseTicket(await apiAs("customer"));
77 for (const path of ["/help/my-tickets?x=1", "/help/my-tickets/new?from=search&q=hi", `/help/my-tickets/${ticket.name}`]) {
78 await page.goto(path);
79 await expect(page).toHaveURL(`/login?redirect-to=${encodeURIComponent(path)}`);
80 }
81 });
82
83 test("R4 an unknown path sends a public guest to login, not a 404", async ({ page, kb }) => {
84 await kb({ public_knowledge_base: 1 });
85 await page.goto("/help/no-such-page");
86 await expect(page).toHaveURL(/\/login\?redirect-to=%2Fhelp%2Fno-such-page/);
87 });
88 });
89
90 test.describe("customer", () => {
91 usePersona("customer");
92
93 test("R5 an unknown path shows Not Found with a way home", async ({ page }) => {
94 await page.goto("/help/no-such-page");
95 await expect(page.getByText("The page you are looking for does not exist.")).toBeVisible();
96 await page.getByRole("link", { name: "Back to Home" }).click();
97 await expect(page).toHaveURL(/\/help\/?$/);
98 });
99
100 test("R6 /help and /help/ open the knowledge base home", async ({ page }) => {
101 for (const path of ["/help", "/help/"]) {
102 await page.goto(path);
103 await expect(page.getByRole("heading", { name: "Here's what might help" })).toBeVisible();
104 }
105 });
106
107 test("R7–R12 old desk portal links land on the new routes", async ({ page, api }) => {
108 const category = await createCategory(api);
109 const article = await createArticle(api, category.name, { title: `Old link ${uid()}` });
110 const cases: [string, RegExp][] = [
111 ["/opendesk/my-tickets?x=1", /\/help\/my-tickets\?x=1$/],
112 ["/opendesk/my-tickets/new?from=article", /\/help\/my-tickets\/new\?from=article$/],
113 ["/opendesk/kb-public", /\/help\/?$/],
114 [`/opendesk/kb-public/articles/${article.name}`, new RegExp(`/help/articles/${article.name}-old-link-`)],
115 [`/opendesk/kb-public/${category.name}`, new RegExp(`/help/category/${category.name}$`)],
116 ];
117 for (const [from, to] of cases) {
118 await page.goto(from);
119 await expect(page, from).toHaveURL(to);
120 }
121 });
122
123 test("R15 any other desk route sends a customer to the portal", async ({ page }) => {
124 await page.goto("/opendesk/teams");
125 await expect(page).toHaveURL(/\/help\/?$/);
126 });
127 });
128});
129
130test.describe("header", () => {
131 test.describe("guest", () => {
132 test.use(GUEST);
133
134 test("H1 H8 logo links home, Log in returns to the page", async ({ page, kb }) => {
135 await kb({ public_knowledge_base: 1 });
136 await page.goto("/help/categories?a=1");
137 await expect(page.getByRole("link", { name: "Opendesk" })).toHaveAttribute("href", "/help/");
138 await expect(accountMenu(page)).toHaveCount(0);
139 await page.getByRole("button", { name: "Log in" }).click();
140 await expect(page).toHaveURL(/\/login\?redirect-to=%2Fhelp%2Fcategories%3Fa%3D1/);
141 });
142 });
143
144 test.describe("guest on a phone", () => {
145 test.use({ ...GUEST, viewport: PHONE });
146
147 test("H10 a guest's logo menu holds only the text links", async ({ page, api, kb }) => {
148 await kb({ public_knowledge_base: 1 });
149 await saveQuickLinks(api, [{ label: "E2E Guest link", url: "https://example.com/guest", open_in_new_tab: 0 }]);
150 try {
151 await page.goto("/help");
152 await accountMenu(page).click();
153 await expect(page.getByRole("menuitem")).toHaveText(["E2E Guest link"]);
154 } finally {
155 await api.delete("OD Form Script", QUICK_LINKS);
156 }
157 });
158 });
159
160 test.describe("customer", () => {
161 usePersona("customer");
162
163 test("H2–H4 account menu goes home, to my tickets and to settings", async ({ page }) => {
164 await page.goto("/help/categories");
165 await accountMenu(page).click();
166 await expect(page.getByRole("menuitem", { name: "Agent portal" })).toHaveCount(0);
167 await page.getByRole("menuitem", { name: "My tickets" }).click();
168 await expect(page).toHaveURL(/\/help\/my-tickets/);
169 await accountMenu(page).click();
170 await page.getByRole("menuitem", { name: "Home" }).click();
171 await expect(page).toHaveURL(/\/help\/?$/);
172 await accountMenu(page).click();
173 await page.getByRole("menuitem", { name: "Settings" }).click();
174 await expect(page).toHaveURL(/\/settings\/profile$/);
175 await expect(page.getByRole("dialog")).toBeVisible();
176 });
177
178 test("H7 theme toggle switches at once and survives a reload", async ({ page }) => {
179 await page.goto("/help");
180 const theme = () => page.evaluate(() => document.documentElement.getAttribute("data-theme"));
181 const before = await theme();
182 await page.getByRole("button", { name: "Toggle theme" }).click();
183 const after = await theme();
184 expect(after).not.toBe(before);
185 await page.reload();
186 await expect.poll(theme).toBe(after);
187 await page.getByRole("button", { name: "Toggle theme" }).click();
188 });
189
190 test("H12 ticket breadcrumbs lead back to the list", async ({ page, apiAs }) => {
191 const ticket = await raiseTicket(await apiAs("customer"));
192 await page.goto("/help/my-tickets/new");
193 await expect(page.getByText("New ticket").first()).toBeVisible();
194 await page.goto(`/help/my-tickets/${ticket.name}`);
195 await expect(page.getByText(ticket.subject).first()).toBeVisible();
196 await page.getByRole("link", { name: "Tickets" }).first().click();
197 await expect(page).toHaveURL(/\/help\/my-tickets/);
198 });
199
200 test("H5 Log out signs out and lands on the home page", async ({ browser, baseURL }) => {
201 // Own session: logging out would end the shared stored one.
202 const context = await browser.newContext({ baseURL, extraHTTPHeaders: siteHeader() });
203 const page = await context.newPage();
204 await page.request.post("/api/method/login", { form: { usr: personas.customer.email, pwd: PASSWORD } });
205 await page.goto("/help/categories");
206 await accountMenu(page).click();
207 await page.getByRole("menuitem", { name: "Log out" }).click();
208 await expect(page).toHaveURL(/\/(help\/?|login.*)$/);
209 const user = await page.request.get("/api/method/frappe.auth.get_logged_user");
210 expect(user.status()).not.toBe(200);
211 await context.close();
212 });
213 });
214
215 test.describe("agent", () => {
216 usePersona("agent");
217
218 test("H6 agents also get Agent portal", async ({ page }) => {
219 await page.goto("/help");
220 await accountMenu(page).click();
221 await page.getByRole("menuitem", { name: "Agent portal" }).click();
222 await expect(page).toHaveURL(/\/opendesk/);
223 });
224 });
225
226 test.describe("quick links and custom actions", () => {
227 usePersona("customer");
228
229 test("H9 H11 quick links and knowledge base actions show on knowledge base pages only", async ({ page, api }) => {
230 const links = [
231 { label: "E2E Docs", url: "https://docs.example.com", open_in_new_tab: 1 },
232 { label: "E2E Same tab", url: "https://example.com/same", open_in_new_tab: 0 },
233 { label: "E2E GitHub", url: "https://github.com/ikhsanddy/opendesk", open_in_new_tab: 1 },
234 { label: "E2E Unsafe", url: "javascript:alert(1)", open_in_new_tab: 0 },
235 ];
236 // Short labels: long ones overflow the phone header onto the logo menu.
237 const action = "E2E";
238 await saveQuickLinks(api, links);
239 const scriptName = `E2E KB Script ${uid()}`;
240 await api.insert("OD Form Script", {
241 __newname: scriptName,
242 dt: "OD Ticket",
243 apply_to: "Form",
244 enabled: 1,
245 apply_to_knowledge_base: 1,
246 script: `function setupForm() { return { actions: [
247 { label: "${action} plain", onClick: () => {} },
248 { group: "${action} grp", buttonLabel: "${action} labelled", options: [{ label: "${action} inside", onClick: () => {} }] },
249 { group: "${action} hidden", options: [{ label: "${action} in more", onClick: () => {} }] },
250 ] } }`,
251 });
252 try {
253 await page.goto("/help");
254 const docs = page.getByRole("link", { name: "E2E Docs" });
255 await expect(docs).toHaveAttribute("href", "https://docs.example.com");
256 await expect(docs).toHaveAttribute("target", "_blank");
257 await expect(docs).toHaveAttribute("rel", /noopener/);
258 await expect(page.getByRole("link", { name: "E2E Same tab" })).not.toHaveAttribute("target", "_blank");
259 await expect(page.getByRole("link", { name: "E2E GitHub" })).toBeVisible();
260 await expect(page.getByRole("link", { name: "E2E GitHub" })).not.toHaveText("E2E GitHub");
261 await expect(page.getByText("E2E Unsafe")).toHaveCount(0);
262
263 await expect(page.getByRole("button", { name: `${action} plain` })).toBeVisible();
264 await page.getByRole("button", { name: `${action} labelled` }).click();
265 await expect(page.getByRole("menuitem", { name: `${action} inside` })).toBeVisible();
266 await page.keyboard.press("Escape");
267 await page.getByRole("button", { name: "More actions" }).click();
268 await expect(page.getByRole("menuitem", { name: `${action} in more` })).toBeVisible();
269 await page.keyboard.press("Escape");
270
271 await page.goto("/help/my-tickets");
272 await expect(page.getByRole("button", { name: "Raise a ticket" })).toBeVisible();
273 await expect(page.getByRole("link", { name: "E2E Docs" })).toHaveCount(0);
274 await expect(page.getByRole("button", { name: `${action} plain` })).toHaveCount(0);
275
276 // H10: on a phone text links move into the logo menu.
277 await page.setViewportSize(PHONE);
278 await page.goto("/help");
279 await expect(page.getByRole("link", { name: "E2E GitHub" })).toBeVisible();
280 await expect(page.getByRole("link", { name: "E2E Docs" })).toHaveCount(0);
281 await accountMenu(page).click();
282 await expect(page.getByRole("menuitem", { name: "E2E Docs" })).toBeVisible();
283 } finally {
284 await api.delete("OD Form Script", scriptName);
285 await api.delete("OD Form Script", QUICK_LINKS);
286 }
287 });
288 });
289
290 test.describe("custom actions on a phone", () => {
291 usePersona("customer");
292 test.use({ viewport: PHONE });
293
294 test("H11 a labelled action menu folds into More actions on a phone", async ({ page, api }) => {
295 const scriptName = `E2E KB Script ${uid()}`;
296 await api.insert("OD Form Script", {
297 __newname: scriptName,
298 dt: "OD Ticket",
299 apply_to: "Form",
300 enabled: 1,
301 apply_to_knowledge_base: 1,
302 script: `function setupForm() { return { actions: [
303 { group: "Help", buttonLabel: "Contact our support team", options: [{ label: "Email us", onClick: () => {} }] },
304 { group: "Other", options: [{ label: "Status page", onClick: () => {} }] },
305 ] } }`,
306 });
307 await saveQuickLinks(api, [{ label: "GitHub", url: "https://github.com/ikhsanddy/opendesk", open_in_new_tab: 1 }]);
308 try {
309 await page.goto("/help");
310 await expect(page.getByRole("link", { name: "GitHub" })).toBeVisible();
311 await expect(page.getByRole("button", { name: "Contact our support team" })).toHaveCount(0);
312 await expect(accountMenu(page)).toBeInViewport({ ratio: 1 });
313 await page.getByRole("button", { name: "More actions" }).click();
314 await expect(page.getByRole("menuitem", { name: "Email us" })).toBeVisible();
315 await expect(page.getByRole("menuitem", { name: "Status page" })).toBeVisible();
316 } finally {
317 await api.delete("OD Form Script", QUICK_LINKS);
318 await api.delete("OD Form Script", scriptName);
319 }
320 });
321 });
322
323 test.describe("drawers on a phone", () => {
324 usePersona("customer");
325 test.use({ viewport: PHONE });
326
327 test("H13 the section bar opens the article sheet; Esc closes it", async ({ page, api }) => {
328 const category = await createCategory(api);
329 const article = await createArticle(api, category.name);
330 await page.goto(`/help/articles/${article.name}`);
331 const sidebar = page.getByRole("navigation", { name: "Knowledge base" });
332 const bar = page.getByRole("button", { name: `Articles / ${category.category_name}` });
333 await expect(sidebar).not.toBeInViewport();
334 await bar.click();
335 const sheet = page.getByRole("dialog", { name: "Browse articles" });
336 await expect(sheet).toBeVisible();
337 await expect(sidebar).toBeInViewport();
338 // The sheet traps focus: it lands on the sheet or inside it.
339 await expect.poll(() => sheet.evaluate((el) => el.contains(document.activeElement))).toBe(true);
340 await page.keyboard.press("Escape");
341 await expect(sidebar).not.toBeInViewport();
342 await expect(bar).toBeFocused();
343 });
344
345 test("H14 Ticket details opens the details drawer; Close details closes it", async ({ page, apiAs }) => {
346 const ticket = await raiseTicket(await apiAs("customer"));
347 await page.goto(`/help/my-tickets/${ticket.name}`);
348 await page.getByRole("button", { name: "Ticket details" }).click();
349 const close = page.getByRole("button", { name: "Close details" });
350 await expect(close).toBeInViewport();
351 await close.click();
352 await expect(close).not.toBeInViewport();
353 });
354 });
355
356 test.describe("admin preview", () => {
357 usePersona("admin");
358
359 test("H15 ?preview=1 shows the stored draft for the rest of the tab", async ({ page, api }) => {
360 const category = await createCategory(api);
361 await createArticle(api, category.name);
362 await page.goto("/help");
363 await page.evaluate(
364 ([pinned]) =>
365 localStorage.setItem(
366 "kb:preview",
367 JSON.stringify({ banner_image: "", banner_preset: "Violet", pinned: [pinned], links: [{ label: "E2E Preview link", url: "https://example.com", open_in_new_tab: false }] })
368 ),
369 [category.name]
370 );
371 await page.goto("/help?preview=1");
372 await expect(page.getByRole("link", { name: "E2E Preview link" })).toBeVisible();
373 await expect(page.getByRole("heading", { name: category.category_name, level: 2 })).toBeVisible();
374 await page.goto("/help/categories");
375 await expect(page.getByRole("link", { name: "E2E Preview link" })).toBeVisible();
376 await page.evaluate(() => localStorage.removeItem("kb:preview"));
377 });
378 });
379});
380
381test.describe("knowledge base home", () => {
382 usePersona("customer");
383
384 test("K1 the hero shows the title and the banner preset", async ({ page, kb }) => {
385 await kb({ banner_preset: "Blue" });
386 await page.goto("/help");
387 const title = page.getByRole("heading", { name: "Here's what might help", level: 1 });
388 await expect(title).toBeVisible();
389 await expect(page.getByText("Explore answers to frequently asked questions")).toBeVisible();
390 const background = await page.evaluate(() =>
391 [...document.querySelectorAll<HTMLElement>("*")].map((el) => el.style.background).find((bg) => bg.includes("surface-blue"))
392 );
393 expect(background).toContain("surface-blue");
394 });
395
396 test("K9 recently viewed is kept per user", async ({ page, api }) => {
397 const category = await createCategory(api);
398 const article = await createArticle(api, category.name);
399 await page.goto(`/help/articles/${article.name}`);
400 await expect(page.getByRole("heading", { name: article.title, level: 1 })).toBeVisible();
401 const lists = await page.evaluate(() =>
402 Object.fromEntries(Object.keys(localStorage).filter((key) => key.startsWith("opendesk-kb-recent")).map((key) => [key, JSON.parse(localStorage.getItem(key) || "[]")]))
403 );
404 expect(lists[`opendesk-kb-recent-articles:${personas.customer.email}`][0].name).toBe(article.name);
405 // The guest list, written during session load, must stay empty.
406 expect.soft(lists["opendesk-kb-recent-articles:Guest"] || []).toEqual([]);
407 });
408
409 test("K10 K12 pinned categories and View all", async ({ page, api }) => {
410 const pinned = [await createCategory(api), await createCategory(api)];
411 for (const category of pinned) await createArticle(api, category.name);
412 try {
413 for (const [index, category] of pinned.entries()) {
414 await api.update("OD Article Category", category.name, { pinned: 1, pinned_order: index + 1 });
415 }
416 await page.goto("/help");
417 const grid = page.locator('[data-component-id="category-grid-repeater"]');
418 await expect(grid.getByRole("link")).toHaveCount(2);
419 await expect(grid.getByRole("link").first()).toContainText(pinned[0].category_name);
420 // K12: two cards take two columns.
421 const columns = await grid.evaluate((el) => getComputedStyle(el).gridTemplateColumns.split(" ").length);
422 expect(columns).toBe(2);
423 await page.getByRole("button", { name: "View all" }).click();
424 await expect(page).toHaveURL(/\/help\/categories$/);
425 await page.goBack();
426 await expect(grid.getByRole("link")).toHaveCount(2);
427 // K11: a card opens its category.
428 await grid.getByRole("link").first().click();
429 await expect(page).toHaveURL(new RegExp(`/help/category/${pinned[0].name}$`));
430 } finally {
431 for (const category of pinned) await api.update("OD Article Category", category.name, { pinned: 0, pinned_order: 0 });
432 }
433 });
434
435 test("K15 a lone pinned category gets its own layout", async ({ page, api }) => {
436 const category = await createCategory(api);
437 const articles = [await createArticle(api, category.name), await createArticle(api, category.name)];
438 try {
439 await api.update("OD Article Category", category.name, { pinned: 1, pinned_order: 1 });
440 await page.goto("/help");
441 await expect(page.getByRole("heading", { name: category.category_name })).toBeVisible();
442 for (const article of articles) await expect(page.getByRole("link", { name: article.title })).toBeVisible();
443 await expect(page.getByRole("radio", { name: "Popular" })).toHaveCount(0);
444 await page.getByRole("button", { name: "View all categories" }).click();
445 await expect(page).toHaveURL(/\/help\/categories$/);
446 } finally {
447 await api.update("OD Article Category", category.name, { pinned: 0, pinned_order: 0 });
448 }
449 });
450
451 test("K14 Latest and Popular tabs sort the articles", async ({ page, api }) => {
452 const category = await createCategory(api);
453 const popular = await createArticle(api, category.name, { title: `Popular ${uid()}` });
454 // Above any earlier run's articles; OD Article.views is a 32 bit int.
455 await api.update("OD Article", popular.name, { views: Math.floor(Date.now() / 1000) });
456 const latest = await createArticle(api, category.name, { title: `Latest ${uid()}` });
457 await page.goto("/help");
458 const articles = page.locator('[data-component-id="Repeater-9o4hxaycp"]').getByRole("link");
459 await expect(articles.first()).toContainText(popular.title);
460 await page.getByRole("radio", { name: "Latest" }).click();
461 await expect(articles.first()).toContainText(latest.title);
462 await page.getByRole("radio", { name: "Popular" }).click();
463 await expect(articles.first()).toContainText(popular.title);
464 await articles.first().press("Enter");
465 await expect(page).toHaveURL(new RegExp(`/help/articles/${popular.name}-`));
466 });
467});
468
469test.describe("audiences", () => {
470 test.describe("guest", () => {
471 test.use(GUEST);
472
473 test("K16 CT3 AR4 AR21 AR24 a guest reads public articles only and cannot vote", async ({ page, api, kb }) => {
474 await kb({ public_knowledge_base: 1, allow_anonymous_article_voting: 0 });
475 const seeded = await seedAudiences(api);
476 const only = await createCategory(api);
477 await createArticle(api, only.name, { visibility: "Customers" });
478
479 await page.goto(`/help/category/${seeded.category.name}`);
480 await expect(page.getByText("1 article", { exact: true })).toBeVisible();
481 await expect(page.getByRole("link", { name: seeded.public.title }).last()).toBeVisible();
482 await expect(page.getByRole("link", { name: seeded.customers.title })).toHaveCount(0);
483
484 await page.goto(`/help/category/${only.name}`);
485 await expect(page.getByText("Category not found")).toBeVisible();
486 await page.goto(`/help/articles/${seeded.customers.name}`);
487 await expect(page.getByText("Article not found")).toBeVisible();
488
489 await page.goto(`/help/articles/${seeded.public.name}`);
490 await expect(page.getByRole("heading", { name: seeded.public.title, level: 1 })).toBeVisible();
491 await expect(page.getByRole("button", { name: "Yes, it was helpful" })).toHaveCount(0);
492 await expect(page.getByRole("link", { name: "here", exact: true })).toHaveCount(0);
493 const vote = await page.request.post("/api/method/opendesk.api.knowledge_base.vote_on_article", {
494 form: { article: seeded.public.name, vote: "1" },
495 });
496 expect(vote.status()).toBe(403);
497 });
498
499 test("AR20 a guest votes with a visitor cookie when anonymous voting is on", async ({ page, api, kb }) => {
500 await kb({ public_knowledge_base: 1, allow_anonymous_article_voting: 1 });
501 const category = await createCategory(api);
502 const article = await createArticle(api, category.name);
503 await page.goto(`/help/articles/${article.name}`);
504 await page.getByRole("button", { name: "Yes, it was helpful" }).click();
505 await expect(page.getByText("Thanks for your feedback!")).toBeVisible();
506 const cookie = (await page.context().cookies()).find((row) => row.name === "od_visitor");
507 expect(cookie?.httpOnly).toBe(true);
508 const [row] = await api.list("OD Article Feedback", { filters: { article: article.name }, fields: ["feedback", "visitor_id", "user"] });
509 expect(row.feedback).toBe("1");
510 expect(row.visitor_id).toBeTruthy();
511 // AR19: the vote is still shown on a revisit.
512 await page.reload();
513 await expect(page.getByRole("button", { name: "Yes, it was helpful" })).toHaveAttribute("aria-pressed", "true");
514 });
515 });
516
517 test.describe("customer", () => {
518 usePersona("customer");
519
520 test("K16 CT1 AR4 a customer reads public and customer articles", async ({ page, api }) => {
521 const seeded = await seedAudiences(api);
522 await page.goto(`/help/category/${seeded.category.name}`);
523 await expect(page.getByRole("heading", { name: seeded.category.category_name, level: 1 })).toBeVisible();
524 await expect(page.getByText("2 articles", { exact: true })).toBeVisible();
525 await expect(page.getByText("By Administrator")).toBeVisible();
526 await expect(page.getByRole("link", { name: seeded.customers.title }).last()).toBeVisible();
527 await expect(page.getByRole("link", { name: seeded.agents.title })).toHaveCount(0);
528 for (const hidden of [seeded.agents, seeded.draft]) {
529 await page.goto(`/help/articles/${hidden.name}`);
530 await expect(page.getByText("Article not found")).toBeVisible();
531 }
532 });
533 });
534
535 test.describe("agent", () => {
536 usePersona("agent");
537
538 test("K16 AR5 an agent reads every audience and drafts", async ({ page, api }) => {
539 const seeded = await seedAudiences(api);
540 await page.goto(`/help/category/${seeded.category.name}`);
541 await expect(page.getByText("3 articles", { exact: true })).toBeVisible();
542 for (const article of [seeded.agents, seeded.draft]) {
543 await page.goto(`/help/articles/${article.name}`);
544 await expect(page.getByRole("heading", { name: article.title, level: 1 })).toBeVisible();
545 }
546 });
547 });
548});
549
550test.describe("categories and category pages", () => {
551 usePersona("customer");
552
553 test("CA1 All categories lists a readable category and opens it", async ({ page, api }) => {
554 const category = await createCategory(api);
555 await createArticle(api, category.name);
556 await page.goto("/help/categories");
557 await page.getByRole("link", { name: category.category_name }).click();
558 await expect(page).toHaveURL(new RegExp(`/help/category/${category.name}$`));
559 });
560
561 test("/ focuses Search categories on All categories, and the article search elsewhere", async ({ page, api }) => {
562 const category = await createCategory(api);
563 await createArticle(api, category.name);
564 await page.goto("/help/categories");
565 const search = page.getByRole("textbox", { name: "Search categories" });
566 await expect(search).toBeVisible();
567 await page.keyboard.press("/");
568 await expect(search).toBeFocused();
569 await expect(search).toHaveValue("");
570
571 await page.goto(`/help/category/${category.name}`);
572 await expect(page.getByPlaceholder("Search articles").first()).toBeVisible();
573 await page.keyboard.press("/");
574 await expect(page.getByPlaceholder("Search articles").first()).toBeFocused();
575 });
576
577 test("CT2–CT5 article list, sidebar toggle and sidebar search", async ({ page, api }) => {
578 const category = await createCategory(api);
579 const other = await createCategory(api);
580 const article = await createArticle(api, category.name, { title: `Sidebar ${uid()}` });
581 const otherArticle = await createArticle(api, other.name, { title: `Elsewhere ${uid()}` });
582
583 await page.goto("/help/category/no-such-category");
584 await expect(page.getByText("Category not found")).toBeVisible();
585
586 await page.goto(`/help/category/${category.name}`);
587 const sidebar = page.getByRole("navigation", { name: "Knowledge base" });
588 await expect(sidebar.getByRole("button", { name: category.category_name })).toHaveAttribute("aria-expanded", "true");
589 await sidebar.getByRole("button", { name: other.category_name }).click();
590 await expect(sidebar.getByRole("button", { name: other.category_name })).toHaveAttribute("aria-expanded", "true");
591 await expect(sidebar.getByRole("button", { name: category.category_name })).toHaveAttribute("aria-expanded", "false");
592 await expect(sidebar.getByRole("link", { name: otherArticle.title })).toBeVisible();
593
594 await page.locator("body").press("/");
595 const search = sidebar.getByRole("textbox", { name: "Search articles" });
596 await expect(search).toBeFocused();
597 await search.fill(article.title);
598 await expect(sidebar.getByRole("link", { name: article.title })).toBeVisible();
599 await expect(sidebar.getByRole("link", { name: otherArticle.title })).toHaveCount(0);
600 await search.fill(`nothing ${uid()}`);
601 await expect(sidebar.getByText(/No articles match/)).toBeVisible();
602 await search.fill("");
603
604 // CT2: the list row opens the article.
605 await page.locator("main, body").getByRole("link", { name: article.title }).last().click();
606 await expect(page).toHaveURL(new RegExp(`/help/articles/${article.name}-`));
607 // AR26: the sidebar marks the article being read.
608 await expect(sidebar.getByRole("link", { name: article.title })).toHaveAttribute("aria-current", "page");
609 });
610});
611
612test.describe("article page", () => {
613 usePersona("customer");
614 test.use({ permissions: ["clipboard-read", "clipboard-write"] });
615
616 test("AR1 AR2 bare and stale links settle on the slug, keeping the hash", async ({ page, api }) => {
617 const category = await createCategory(api);
618 const article = await createArticle(api, category.name, { title: `Slug test ${uid()}` });
619 const slug = article.title.toLowerCase().replace(/\s+/g, "-");
620 await page.goto(`/help/articles/${article.name}#part`);
621 await expect(page).toHaveURL(`/help/articles/${article.name}-${slug}#part`);
622 await page.goto(`/help/articles/${article.name}-an-old-title`);
623 await expect(page).toHaveURL(`/help/articles/${article.name}-${slug}`);
624 });
625
626 test("AR3 a heading link opens at that heading", async ({ page, api }) => {
627 const category = await createCategory(api);
628 const filler = "<p>" + "word ".repeat(600) + "</p>";
629 const article = await createArticle(api, category.name, {
630 content: `<h2>First part</h2>${filler}<h2>Deep part</h2>${filler}`,
631 });
632 await page.goto(`/help/articles/${article.name}#deep-part`);
633 const heading = page.getByRole("heading", { name: "Deep part" });
634 await expect(heading).toBeVisible();
635 await expect(heading, "the page should scroll to the linked heading").toBeInViewport({ timeout: 2000 });
636 });
637
638 test("AR3 a heading link from the old knowledge base still opens at that heading", async ({ page, api }) => {
639 const category = await createCategory(api);
640 const filler = "<p>" + "word ".repeat(600) + "</p>";
641 const article = await createArticle(api, category.name, {
642 content: `<h2>First part</h2>${filler}<h2>What's new?</h2>${filler}`,
643 });
644 // The old desk kept the trailing dash; the portal's own id is `what-s-new`.
645 await page.goto(`/help/articles/${article.name}#what-s-new-`);
646 const heading = page.getByRole("heading", { name: "What's new?" });
647 await expect(heading).toBeVisible();
648 await expect(heading, "the page should scroll to the linked heading").toBeInViewport({ timeout: 2000 });
649 });
650
651 test("AR6 AR7 AR9–AR12 meta line, view count and copy actions", async ({ page, api, context }) => {
652 const category = await createCategory(api);
653 const article = await createArticle(api, category.name, { title: `Copy me ${uid()}`, visibility: "Customers" });
654 await page.goto(`/help/articles/${article.name}`);
655 await expect(page.getByText("1 minute to read")).toBeVisible();
656 await expect(page.getByText("Administrator").last()).toBeVisible();
657 await expect.poll(async () => (await api.get("OD Article", article.name)).views).toBe(1);
658
659 await page.getByText("Copy link").click();
660 await expect(page.getByText("Link copied")).toBeVisible();
661 expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(page.url());
662
663 await page.getByRole("button", { name: "Copy for LLM" }).click();
664 await expect(page.getByText("Copied as Markdown")).toBeVisible();
665 expect(await page.evaluate(() => navigator.clipboard.readText())).toContain(`# ${article.title}`);
666
667 await page.getByRole("button", { name: "More ways to use this article with an LLM" }).click();
668 await expect(page.getByRole("menuitem", { name: "Copy as Markdown" })).toBeVisible();
669 // AR12: a Customers article is never offered to outside LLMs.
670 await expect(page.getByRole("menuitem", { name: "Open in ChatGPT" })).toHaveCount(0);
671 const popup = context.waitForEvent("page");
672 await page.getByRole("menuitem", { name: "View as Markdown" }).click();
673 const markdown = await popup;
674 await expect(markdown.locator("body")).toContainText(`# ${article.title}`);
675 await markdown.close();
676
677 // AR7: no second view within the hour; wait for the request first.
678 const counted = page.waitForResponse((response) => response.url().includes("increment_views"));
679 await page.reload();
680 await counted;
681 expect((await api.get("OD Article", article.name)).views).toBe(1);
682 });
683
684 test("AR17 AR18 AR22 a vote can change, clear, and is rate limited", async ({ page, api }) => {
685 const category = await createCategory(api);
686 const article = await createArticle(api, category.name);
687 const no = page.getByRole("button", { name: "No, it wasn't helpful" });
688 const yes = page.getByRole("button", { name: "Yes, it was helpful" });
689 const feedback = async () =>
690 (await api.list("OD Article Feedback", { filters: { article: article.name, user: personas.customer.email }, fields: ["feedback"] }))[0]?.feedback;
691 await page.goto(`/help/articles/${article.name}`);
692 await no.click();
693 await expect(no).toHaveAttribute("aria-pressed", "true");
694 await expect.poll(feedback).toBe("2");
695 await yes.click();
696 await expect.poll(feedback).toBe("1");
697 await yes.click();
698 await expect(yes).toHaveAttribute("aria-pressed", "false");
699 await expect.poll(feedback).toBe("0");
700 await no.click();
701 await yes.click();
702 await expect(page.getByText("Thanks for your feedback!").first()).toBeVisible();
703 // The sixth vote on one article within an hour is refused.
704 await expect.poll(feedback).toBe("1");
705 const refused = page.waitForResponse(
706 (response) => response.url().includes("vote_on_article") && /"vote":\s*2/.test(response.request().postData() || "")
707 );
708 await no.click();
709 expect((await refused).status()).toBe(429);
710 await expect(page.locator("[data-sonner-toast][data-type=error]")).toBeVisible();
711 expect(await feedback()).toBe("1");
712 });
713
714 test("AR14 below xl the inline On this page jumps to a heading", async ({ page, api }) => {
715 await page.setViewportSize({ width: 1024, height: 800 });
716 const category = await createCategory(api);
717 const filler = "<p>" + "word ".repeat(400) + "</p>";
718 const article = await createArticle(api, category.name, {
719 content: `<h2>Alpha part</h2>${filler}<h3>Beta part</h3>${filler}<h2>Gamma part</h2>${filler}`,
720 });
721 await page.goto(`/help/articles/${article.name}`);
722 await page.getByRole("button", { name: "On this page" }).click();
723 await page.getByRole("button", { name: "Gamma part" }).click();
724 await expect(page.getByRole("heading", { name: "Gamma part" })).toBeInViewport();
725 });
726
727 test("AR15 at xl a single heading still gets the side contents", async ({ page, api }) => {
728 await page.setViewportSize({ width: 1440, height: 900 });
729 const category = await createCategory(api);
730 const article = await createArticle(api, category.name, { content: "<h2>Only part</h2><p>Body</p>" });
731 await page.goto(`/help/articles/${article.name}`);
732 await expect(page.getByRole("heading", { name: "Only part" })).toBeVisible();
733 const toc = page.getByRole("navigation").filter({ hasText: "On this page" });
734 await expect.soft(toc.getByRole("button", { name: "Only part" }), "one heading still shows the xl TOC").toBeVisible();
735 });
736
737 test("AR25 AR27 related articles open and reset the article scroll", async ({ page, api }) => {
738 const category = await createCategory(api);
739 const filler = "<p>" + "word ".repeat(800) + "</p>";
740 const created = [];
741 for (let i = 0; i < 8; i++) created.push(await createArticle(api, category.name, { title: `Related ${i} ${uid()}`, content: filler }));
742 await page.goto(`/help/articles/${created[0].name}`);
743 await expect(page.getByText("Related articles")).toBeVisible();
744 const rows = page.locator('[data-component-id="container-article-scroll"]').getByRole("link", { name: /^Related \d/ });
745 await expect(rows).toHaveCount(5);
746 const panel = page.locator('[data-component-id="container-article-scroll"]');
747 await panel.evaluate((el) => el.scrollTo({ top: el.scrollHeight }));
748 const target = rows.first();
749 const title = (await target.innerText()).split("\n")[0];
750 await target.click();
751 await expect(page.getByRole("heading", { name: title, level: 1 })).toBeVisible();
752 await expect.poll(() => panel.evaluate((el) => el.scrollTop)).toBe(0);
753 });
754
755 test.describe("phone", () => {
756 test.use({ viewport: PHONE });
757
758 test("AR13 the Article actions menu holds the copy actions", async ({ page, api }) => {
759 const category = await createCategory(api);
760 const article = await createArticle(api, category.name);
761 await page.goto(`/help/articles/${article.name}`);
762 await expect(page.getByText("Copy link")).toBeHidden();
763 await page.getByRole("button", { name: "Article actions" }).click();
764 for (const name of ["Copy for LLM", "Copy link", "View as Markdown"]) {
765 await expect(page.getByRole("menuitem", { name })).toBeVisible();
766 }
767 });
768 });
769});
770
771test.describe("article page, public knowledge base", () => {
772 usePersona("customer");
773
774 test("AR12 a public article offers Open in ChatGPT and Claude", async ({ page, api, kb, context }) => {
775 await kb({ public_knowledge_base: 1 });
776 const category = await createCategory(api);
777 const article = await createArticle(api, category.name);
778 await page.goto(`/help/articles/${article.name}`);
779 await page.getByRole("button", { name: "More ways to use this article with an LLM" }).click();
780 await expect(page.getByRole("menuitem", { name: "Open in ChatGPT" })).toBeVisible();
781 const popup = context.waitForEvent("page");
782 await page.getByRole("menuitem", { name: "Open in Claude" }).click();
783 const claude = await popup;
784 expect(decodeURIComponent(claude.url())).toContain(`get_article_markdown?name=${article.name}`);
785 await claude.close();
786 });
787});
788
789test.describe("knowledge base settings", () => {
790 usePersona("admin");
791
792 test("S30–S34 the Knowledge Base panel saves toggles, pins, banner and links", async ({ page, api, kb, context }) => {
793 await kb({ public_knowledge_base: 1, allow_anonymous_article_voting: 0, banner_preset: "" });
794 const category = await createCategory(api);
795 await createArticle(api, category.name);
796 const label = `E2E Link ${uid()}`;
797 try {
798 await page.goto("/help");
799 await accountMenu(page).click();
800 await page.getByRole("menuitem", { name: "Settings" }).click();
801 const dialog = page.getByRole("dialog");
802 await dialog.getByRole("tab", { name: "Knowledge Base", exact: true }).click();
803 await expect(page).toHaveURL(/\/settings\/knowledge-base$/);
804
805 // Switches save without the Save button.
806 await dialog.getByText("Allow guests to vote on articles").locator("xpath=../..").getByRole("switch").click();
807 await expect.poll(async () => (await api.get("OD Settings", "OD Settings")).allow_anonymous_article_voting).toBe(1);
808 await expect(dialog.getByText("Unsaved")).toBeHidden();
809 await dialog.getByRole("radio", { name: "Blue" }).click();
810 await expect(dialog.getByText("Unsaved")).toBeVisible();
811 // Pins save from their own dialog, apart from the draft.
812 await dialog.getByRole("button", { name: "Customize" }).click();
813 const pins = page.getByRole("dialog", { name: "Pinned categories" });
814 await pins.getByRole("button", { name: `Pin ${category.category_name}`, exact: true }).click();
815 await pins.getByRole("button", { name: "Save" }).click();
816 await expect(pins).toBeHidden();
817 await expect.poll(async () => (await api.get("OD Article Category", category.name)).pinned).toBe(1);
818 await dialog.getByRole("button", { name: "Add link" }).click();
819 await dialog.getByPlaceholder("Contact sales").fill(label);
820 // S33: an unsafe address is refused on Save.
821 await dialog.getByPlaceholder("https://example.com/contact").fill("javascript:alert(1)");
822 await dialog.getByRole("button", { name: "Save" }).click();
823 await expect(page.getByText(`${label}: use a web address`)).toBeVisible();
824 await dialog.getByPlaceholder("https://example.com/contact").fill("https://example.com/e2e");
825
826 // S34: Preview opens the portal with the draft.
827 const popup = context.waitForEvent("page");
828 await dialog.getByRole("button", { name: "Preview" }).click();
829 const preview = await popup;
830 await expect(preview).toHaveURL(/\/help\/?\?preview=1/);
831 await expect(preview.getByRole("link", { name: label })).toBeVisible();
832 await preview.close();
833
834 await dialog.getByRole("button", { name: "Save" }).click();
835 await expect(dialog.getByText("Unsaved")).toBeHidden();
836 const settings = await api.get("OD Settings", "OD Settings");
837 expect(settings.banner_preset).toBe("Blue");
838 expect((await api.get("OD Article Category", category.name)).pinned).toBe(1);
839 expect((await api.get("OD Form Script", QUICK_LINKS)).script).toContain(label);
840 } finally {
841 await api.update("OD Article Category", category.name, { pinned: 0, pinned_order: 0 });
842 if (await api.exists("OD Form Script", { name: QUICK_LINKS })) await api.delete("OD Form Script", QUICK_LINKS);
843 }
844 });
845});
846
847test.describe("knowledge base settings: pins on the home page", () => {
848 usePersona("admin");
849
850 test("pins saved from Settings show on the home page without a reload", async ({ page, api }) => {
851 const pinned = [await createCategory(api), await createCategory(api)];
852 for (const category of pinned) await createArticle(api, category.name);
853 try {
854 await page.goto("/help/settings/knowledge-base");
855 const grid = page.locator('[data-component-id="category-grid-repeater"]');
856 // The settings dialog hides the page from the accessibility tree, so match by CSS.
857 const cards = grid.locator("a");
858 await expect.poll(() => cards.count()).toBeGreaterThan(2);
859 await page.getByRole("dialog").getByRole("button", { name: "Customize" }).click();
860 const pins = page.getByRole("dialog", { name: "Pinned categories" });
861 for (const category of pinned) {
862 await pins.getByRole("button", { name: `Pin ${category.category_name}`, exact: true }).click();
863 }
864 await pins.getByRole("button", { name: "Save" }).click();
865 await expect(pins).toBeHidden();
866 await expect(cards).toHaveCount(2);
867 await expect(cards.first()).toContainText(pinned[0].category_name);
868 } finally {
869 for (const category of pinned) await api.update("OD Article Category", category.name, { pinned: 0, pinned_order: 0 });
870 }
871 });
872});
873
874test.describe("knowledge base settings: pin row menu", () => {
875 usePersona("admin");
876
877 test("S38 a pinned row's menu edits the draft; Save writes the order", async ({ page, api }) => {
878 const first = await createCategory(api);
879 const second = await createCategory(api);
880 await api.update("OD Article Category", first.name, { pinned: 1, pinned_order: 1 });
881 await api.update("OD Article Category", second.name, { pinned: 1, pinned_order: 2 });
882 try {
883 await page.goto("/help/settings/knowledge-base");
884 const dialog = page.getByRole("dialog");
885 await dialog.getByRole("button", { name: `Options for ${first.category_name}` }).click();
886 await page.getByRole("menuitem", { name: "Move down" }).click();
887 await expect(dialog.getByText("Unsaved")).toBeVisible();
888 expect((await api.get("OD Article Category", first.name)).pinned_order).toBe(1);
889 await dialog.getByRole("button", { name: "Save" }).click();
890 await expect(dialog.getByText("Unsaved")).toBeHidden();
891 expect((await api.get("OD Article Category", first.name)).pinned_order).toBe(2);
892 expect((await api.get("OD Article Category", second.name)).pinned_order).toBe(1);
893
894 await dialog.getByRole("button", { name: `Options for ${second.category_name}` }).click();
895 await page.getByRole("menuitem", { name: "Unpin" }).click();
896 await expect(dialog.getByText("Unsaved")).toBeVisible();
897 expect((await api.get("OD Article Category", second.name)).pinned).toBe(1);
898 await dialog.getByRole("button", { name: "Save" }).click();
899 await expect(dialog.getByText("Unsaved")).toBeHidden();
900 expect((await api.get("OD Article Category", second.name)).pinned).toBe(0);
901 expect((await api.get("OD Article Category", first.name)).pinned_order).toBe(1);
902 } finally {
903 for (const category of [first, second]) {
904 await api.update("OD Article Category", category.name, { pinned: 0, pinned_order: 0 });
905 }
906 }
907 });
908});
909
910async function saveQuickLinks(api, links: Record<string, any>[]) {
911 const json = JSON.stringify(links);
912 await api.insert("OD Form Script", {
913 __newname: QUICK_LINKS,
914 dt: "OD Ticket",
915 apply_to: "Form",
916 enabled: 1,
917 apply_to_knowledge_base: 1,
918 script: `function setupForm() {\n return { links: ${json} };\n}`,
919 });
920}
921
922