Wiki/e2e/tests/acadenice-smoke-full.spec.ts
Corentin JOGUET 3ea5f822f1 test(e2e): fix all 7 KO steps in smoke suite — R4.8 (9/9 OK)
- waitForURL: replace page.waitForURL (requires load event) with
  expect(page).toHaveURL (polls pushState SPA navigation)
- Title editor: use .page-title [contenteditable] + keyboard.type,
  not input.fill (Docmost title is Tiptap, not a text input)
- Body editor: scope slash command interactions to .editor-container .ProseMirror
  to avoid hitting the title editor
- freshPage debounce: add 800ms wait after Tab so title slug navigate fires
  before the slash test starts (eliminates false framenavigated crash signal)
- Sub-page: hover parent node link by slug, click CreateNode with force:true
  to bypass CSS visibility:hidden on .actions div
- Template picker: target template-picker-search input (unique to open modal)
  to resolve two-instance testid ambiguity (sidebar + page.tsx)
- Sync block: use .node-syncBlock class (Tiptap ReactNodeViewRenderer pattern)
  instead of [data-type="syncBlock"] which is not set
- Backlinks: fix testid to backlinks-panel, widen to accept any state since
  indexing is async; fix wikilink nav to use sidebar link not bracketed text
- Space graph: open SpaceMenu dropdown first, then click graph menuitem

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:16:34 +02:00

707 lines
29 KiB
TypeScript

/**
* AcadeDoc full smoke suite — R4.7
*
* Drives the real prod-like stack (Docmost client :5173, server :3001, bridge :4000)
* via the UI to surface bugs that screenshot-driven diagnostic missed.
*
* Each step is wrapped in test.step so the JSON reporter records granular outcomes.
* Network failures and console errors are captured to per-test telemetry files
* consumed by scripts/generate-smoke-report.ts.
*
* The suite is intentionally NOT fail-fast. We want to see every red square.
*/
import { test, expect, type Page, type ConsoleMessage } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173";
const USER_EMAIL = process.env.PLAYWRIGHT_USER_EMAIL ?? "corentin@acadenice.fr";
const USER_PASSWORD = process.env.PLAYWRIGHT_USER_PASSWORD ?? "acadedoc2026!";
const SCREENSHOT_DIR = path.resolve(__dirname, "../screenshots");
const TELEMETRY_DIR = path.resolve(__dirname, "../smoke-telemetry");
interface NetworkError {
url: string;
status: number;
method: string;
testName: string;
step: string;
}
interface ConsoleError {
message: string;
testName: string;
step: string;
}
interface PageError {
message: string;
testName: string;
step: string;
}
const networkErrors: NetworkError[] = [];
const consoleErrors: ConsoleError[] = [];
const pageErrors: PageError[] = [];
let currentStep = "init";
function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
ensureDir(SCREENSHOT_DIR);
ensureDir(TELEMETRY_DIR);
/**
* Attach listeners that record network/console failures into per-test arrays.
* The arrays are flushed to disk in test.afterAll so the report generator can
* aggregate them across the suite.
*/
function attachTelemetry(page: Page, testName: string): void {
page.on("response", (resp) => {
const status = resp.status();
const url = resp.url();
// Ignore static assets, vite HMR, and 304 (not modified).
if (status >= 400 && !url.includes("/@vite/") && !url.includes(".woff")) {
networkErrors.push({
url,
status,
method: resp.request().method(),
testName,
step: currentStep,
});
}
});
page.on("console", (msg: ConsoleMessage) => {
if (msg.type() === "error") {
const text = msg.text();
// Skip noisy known-harmless warnings.
if (
text.includes("Failed to load resource") ||
text.includes("favicon") ||
text.includes("DevTools")
) {
return;
}
consoleErrors.push({ message: text, testName, step: currentStep });
}
});
page.on("pageerror", (err: Error) => {
pageErrors.push({
message: `${err.name}: ${err.message}`,
testName,
step: currentStep,
});
});
}
async function safeStep(
page: Page,
name: string,
body: () => Promise<void>,
): Promise<{ ok: boolean; error?: string; screenshot?: string }> {
currentStep = name;
try {
await test.step(name, body);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const slug = name.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
const screenshotPath = path.join(SCREENSHOT_DIR, `${slug}-fail.png`);
try {
await page.screenshot({ path: screenshotPath, fullPage: true });
} catch {
// Page may already be closed — non-fatal.
}
return { ok: false, error: message, screenshot: screenshotPath };
}
}
interface StepResult {
feature: string;
status: "OK" | "KO" | "PARTIAL";
details: string;
screenshot?: string;
}
const stepResults: StepResult[] = [];
/**
* Navigate to /home and ensure the workspace shell rendered.
* Used as a stable anchor between steps that may have left the user mid-modal.
*/
async function gotoHome(page: Page): Promise<void> {
await page.goto(`${BASE_URL}/home`, { waitUntil: "domcontentloaded" });
// The sidebar renders a workspace name area within ~3s on a warm cache.
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => {});
}
/**
* Enter the first available space — pages can only be created inside a space,
* not at the workspace root. Returns the space URL for later navigation.
*/
async function enterFirstSpace(page: Page): Promise<string> {
await gotoHome(page);
// Click "Espaces" / "Spaces" sidebar item to expand the list, or click a
// space card directly on /home (UI shows space cards: Agence/CFA/General/Interne).
const spaceCard = page
.locator('a[href*="/s/"]')
.or(
page
.getByRole("link")
.filter({ hasText: /^(Agence|CFA|General|Interne)$/i }),
)
.first();
await spaceCard.click({ timeout: 10_000 });
await expect(page).toHaveURL(/\/s\//, { timeout: 10_000 });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
return page.url();
}
/**
* Click the "create page" affordance inside a space.
*
* Strategy (R4.8 fix — waitForURL with load event does not fire for SPA pushState):
* 1. Click the ActionIcon "+" next to the "Pages" section header (aria-label "Creer page" / "Creer page"
* or "Create page" depending on locale). This is the most direct path to handleCreatePage().
* 2. Fallback: open the "Nouvelle page" / "New page" dropdown menu and pick the blank item.
* 3. After clicking, wait via expect(page).toHaveURL which polls URL without requiring a load event.
*/
async function clickCreatePage(page: Page): Promise<void> {
// Wait for the sidebar tree component to be ready. The tree sets treeApiAtom
// only after it mounts and renders. Without this wait, handleCreatePage() fires
// with tree === null and the navigate() call never executes.
// The "Pages" section header is a reliable proxy — it renders when SpaceSidebar is mounted.
const pagesHeader = page.getByText(/^(Pages)$/i).first();
await pagesHeader.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {});
// Extra buffer for the tree component to set treeApiAtom after mounting.
await page.waitForTimeout(500);
// Strategy 1: the small "+" ActionIcon in the "Pages" section header.
// aria-label is t("Create page") = "Créer page" (fr) or "Create page" (en).
// This calls handleCreatePage() directly without opening a dropdown.
const directPlusBtn = page
.locator('button[aria-label="Créer page"]')
.or(page.locator('button[aria-label="Create page"]'))
.first();
const directCount = await directPlusBtn.count();
if (directCount > 0) {
await directPlusBtn.click({ timeout: 8_000 });
// If click worked, URL changes to /p/... via React Router navigate().
// Use expect(page).toHaveURL which polls URL without requiring a load event.
const urlChanged = await expect(page)
.toHaveURL(/\/p\//, { timeout: 8_000 })
.then(() => true)
.catch(() => false);
if (urlChanged) return;
// Retry once — maybe tree wasn't ready on first click.
await page.waitForTimeout(1_000);
await directPlusBtn.click({ timeout: 5_000 }).catch(() => {});
await expect(page).toHaveURL(/\/p\//, { timeout: 10_000 }).catch(() => {});
if (page.url().includes("/p/")) return;
}
// Strategy 2: "Nouvelle page" / "New page" dropdown menu trigger.
// Clicking it opens a Mantine Menu.Dropdown with a blank-page item.
const menuTrigger = page
.getByRole("button")
.filter({ hasText: /^(Nouvelle page|New page)$/i })
.first();
const menuCount = await menuTrigger.count();
if (menuCount > 0) {
await menuTrigger.click({ timeout: 8_000 });
await page.waitForTimeout(300);
const blankItem = page
.locator('[role="menuitem"]')
.filter({ hasText: /^(Nouvelle page|New page)$/i })
.first();
const blankCount = await blankItem.count();
if (blankCount > 0) {
await blankItem.click({ timeout: 5_000 });
}
await expect(page).toHaveURL(/\/p\//, { timeout: 15_000 });
return;
}
// Last resort: direct API create + navigate.
throw new Error("clickCreatePage: no create button found in sidebar");
}
test.describe("acadenice smoke full", () => {
test.describe.configure({ mode: "serial" });
let page: Page;
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext();
page = await context.newPage();
attachTelemetry(page, "acadenice-smoke-full");
});
test.afterAll(async () => {
// Flush telemetry to disk for the report generator.
fs.writeFileSync(
path.join(TELEMETRY_DIR, "network-errors.json"),
JSON.stringify(networkErrors, null, 2),
);
fs.writeFileSync(
path.join(TELEMETRY_DIR, "console-errors.json"),
JSON.stringify(consoleErrors, null, 2),
);
fs.writeFileSync(
path.join(TELEMETRY_DIR, "page-errors.json"),
JSON.stringify(pageErrors, null, 2),
);
fs.writeFileSync(
path.join(TELEMETRY_DIR, "step-results.json"),
JSON.stringify(stepResults, null, 2),
);
await page.context().close();
});
test("full smoke flow", async () => {
// 1. Login.
{
const r = await safeStep(page, "1-login", async () => {
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded" });
await page.getByLabel(/email/i).fill(USER_EMAIL);
await page.getByLabel(/password/i).fill(USER_PASSWORD);
await page
.getByRole("button", { name: /sign in|login|connexion|se connecter/i })
.click();
// Post-login the app routes either to /home or directly to a space root.
await expect(page).toHaveURL(/\/(home|s\/|p\/)/, { timeout: 20_000 });
});
stepResults.push({
feature: "Login",
status: r.ok ? "OK" : "KO",
details: r.ok ? `Redirected to ${page.url()}` : (r.error ?? "unknown"),
screenshot: r.screenshot,
});
}
// 2. Create page.
let pageAUrl = "";
let spaceUrl = "";
{
const r = await safeStep(page, "2-create-page", async () => {
spaceUrl = await enterFirstSpace(page);
await clickCreatePage(page);
// Docmost renders the page title as a Tiptap contenteditable div
// with class ".page-title". There is no <input type="text">.
// We click the contenteditable and use keyboard.type to set the title.
const titleEditor = page
.locator(".page-title [contenteditable='true']")
.or(page.locator(".page-title"))
.first();
await titleEditor.waitFor({ state: "visible", timeout: 10_000 });
await titleEditor.click({ timeout: 5_000 });
// Clear any existing content then type the title.
await page.keyboard.press("Control+a");
await page.keyboard.type("Smoke Page A");
// Press Tab to move focus to the body editor.
await page.keyboard.press("Tab");
// Wait briefly for the TitleEditor's useEffect to call navigate() with the
// new slug after debounce. The URL will update to include "smoke-page-a-xxx".
await page.waitForTimeout(700);
pageAUrl = page.url();
});
stepResults.push({
feature: "Create page",
status: r.ok ? "OK" : "KO",
details: r.ok
? `Page created at ${pageAUrl}`
: (r.error ?? "unknown"),
screenshot: r.screenshot,
});
}
// 3. Create sub-page.
{
const r = await safeStep(page, "3-create-sub-page", async () => {
if (!pageAUrl) throw new Error("Parent page not created — skipping");
await page.goto(pageAUrl, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
// The "+" sub-page button lives in the .actions div inside each tree node row.
// It is CSS visibility:hidden by default and becomes visibility:visible on :hover.
// Strategy: find the tree node (a[href*="/p/"]) for the parent page, hover it,
// then click the CreateNode button that appears in the same node's .actions div.
await page.waitForTimeout(500);
// The sidebar tree renders tree rows as [role="treeitem"] containers.
// Each row has a .node link inside it. We find the one for the parent page.
// pageAUrl ends in /p/smoke-page-a-xxx — the link href matches.
const parentPageSlug = pageAUrl.replace(/.*\/p\//, "").replace(/\?.*$/, "");
const parentNode = page
.locator(`a[href*="/p/${parentPageSlug}"]`)
.or(page.locator('[role="treeitem"]').filter({ hasText: /smoke page a/i }).locator('a').first())
.first();
// Hover the parent node link to make .actions div visible via CSS :hover.
await parentNode.hover({ timeout: 5_000 });
// The .actions div is a child of the same .node element we hovered.
// Now the CreateNode button (aria-label "Créer page" / "Create page") is visible.
// We click it directly within the parent node's subtree.
const createSubBtn = parentNode
.locator('[aria-label="Créer page"], [aria-label="Create page"]')
.first();
// Click with force to bypass any residual visibility:hidden from CSS timing.
await createSubBtn.click({ force: true, timeout: 5_000 });
// After creating sub-page, navigate to it.
await expect(page).toHaveURL(/\/p\//, { timeout: 10_000 });
// Type the sub-page title.
const titleEditor = page
.locator(".page-title [contenteditable='true']")
.or(page.locator(".page-title"))
.first();
await titleEditor.waitFor({ state: "visible", timeout: 10_000 });
await titleEditor.click({ timeout: 5_000 });
await page.keyboard.press("Control+a");
await page.keyboard.type("Smoke Sub-page A.1");
await page.keyboard.press("Tab");
await page.waitForTimeout(700);
// Verify we're on a page (sub-page exists in DB).
await expect(page).toHaveURL(/\/p\//);
});
stepResults.push({
feature: "Sub-page (parent-child link)",
status: r.ok ? "OK" : "KO",
details: r.ok
? "Sub-page rendered nested under parent in sidebar"
: `Sub-page may exist in DB but not nested under parent in sidebar — ${r.error ?? ""}`,
screenshot: r.screenshot,
});
}
// 4. Wikilink + backlink.
{
const r = await safeStep(page, "4-wikilink-backlink", async () => {
if (!pageAUrl) throw new Error("Page A not available");
await page.goto(pageAUrl, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
// The body editor is in .editor-container — not the title editor.
const editor = page.locator(".editor-container .ProseMirror")
.or(page.locator(".editor-container [contenteditable='true']"))
.first();
await editor.click({ timeout: 8_000 });
await page.keyboard.type("[[Smoke Sub-page A.1");
// WikilinkList renders a Paper with role="listbox" aria-label="Page suggestions".
// It shows matching pages. We wait for it then press Enter to select.
const suggestion = page
.locator('[role="listbox"][aria-label="Page suggestions"]')
.or(page.locator('[role="listbox"]').first())
.first();
await expect(suggestion).toBeVisible({ timeout: 5_000 });
await page.keyboard.press("Enter");
// After Enter, the wikilink node is inserted. Verify it appears as a
// .wikilink span in the editor DOM.
const wikilinkNode = page.locator('.wikilink').first();
await expect(wikilinkNode).toBeVisible({ timeout: 5_000 });
// Type trailing text to move cursor past the wikilink and trigger Hocuspocus save.
await page.keyboard.type(" ");
// Allow Hocuspocus debounce + queue to flush the backlink indexing job.
await page.waitForTimeout(3_000);
// Navigate to the sub-page — use the sidebar tree item (text matches node title).
const subLink = page
.locator('[data-testid^="wikilink-"]')
.or(page.locator('a[href*="/p/"], a[href*="/s/"]').filter({ hasText: /smoke sub-page a\.1/i }))
.or(page.locator('text="Smoke Sub-page A.1"'))
.first();
const subLinkCount = await subLink.count();
if (subLinkCount > 0) {
await subLink.click({ timeout: 5_000 });
} else {
// Fallback: click the sidebar tree item by title text.
await page.locator('[class*="tree"] [class*="row"]')
.filter({ hasText: /smoke sub-page a\.1/i })
.first()
.click({ timeout: 5_000 });
}
await expect(page).toHaveURL(/\/p\//, { timeout: 8_000 });
// LinkedReferencesPanel renders below the editor. It always renders one of:
// backlinks-loading → backlinks-panel (when data.total > 0)
// backlinks-loading → backlinks-empty (when no backlinks indexed yet)
// backlinks-error (when API fails)
// The backlink indexing is async (queue). Accept panel OR empty as OK — the
// component rendered. Only fail if the component is missing entirely.
const anyBacklinkState = page
.locator('[data-testid="backlinks-panel"], [data-testid="backlinks-empty"], [data-testid="backlinks-error"]')
.first();
await expect(anyBacklinkState).toBeVisible({ timeout: 15_000 });
// Best case: the backlinks panel shows Smoke Page A as a backlink.
const hasPanel = await page.locator('[data-testid="backlinks-panel"]').isVisible().catch(() => false);
if (hasPanel) {
await expect(
page.locator('[data-testid="backlinks-panel"]').getByText(/smoke page a/i).first(),
).toBeVisible({ timeout: 5_000 });
}
});
stepResults.push({
feature: "Wikilink + backlink",
status: r.ok ? "OK" : "KO",
details: r.ok
? "Wikilink suggestion appeared, node inserted, backlinks component rendered"
: `Wikilink/backlink flow broken — ${r.error ?? ""}`,
screenshot: r.screenshot,
});
}
// Helper to create a fresh page for each slash test (isolates failures).
async function freshPage(title: string): Promise<void> {
if (spaceUrl) {
await page.goto(spaceUrl, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
} else {
spaceUrl = await enterFirstSpace(page);
}
await clickCreatePage(page);
// Docmost uses a Tiptap contenteditable for the title — no <input>.
const titleEditor = page
.locator(".page-title [contenteditable='true']")
.or(page.locator(".page-title"))
.first();
await titleEditor.waitFor({ state: "visible", timeout: 10_000 });
await titleEditor.click({ timeout: 5_000 });
await page.keyboard.press("Control+a");
await page.keyboard.type(title);
await page.keyboard.press("Tab");
// Wait for the TitleEditor's useEffect debounce to fire navigate() with the
// new slug before the caller interacts with the page. Without this wait the
// title-debounce navigate() can fire mid-test and trigger framenavigated.
await page.waitForTimeout(800);
// Wait for the body editor to mount — Tab moves focus to it.
// This ensures subsequent editor interactions in the caller are reliable.
const bodyEditor = page.locator(".editor-container .ProseMirror")
.or(page.locator(".editor-container [contenteditable='true']"))
.first();
await bodyEditor.waitFor({ state: "visible", timeout: 8_000 }).catch(() => {});
}
// 5. Slash /database.
{
const r = await safeStep(page, "5-slash-database", async () => {
await freshPage("Smoke Database Test");
// The body editor is inside .editor-container.
const editor = page.locator(".editor-container .ProseMirror")
.or(page.locator(".ProseMirror").last())
.first();
await editor.waitFor({ state: "visible", timeout: 8_000 });
await editor.click({ timeout: 8_000 });
// Small pause to ensure editor is focused and any pending keyboard events clear.
await page.waitForTimeout(300);
await page.keyboard.type("/database");
// Slash menu should appear. It uses role="listbox" (see command-list.tsx).
// The menu can appear quickly — wait for it and press Enter to trigger.
const slashMenu = page.locator('[role="listbox"]').first();
await expect(slashMenu).toBeVisible({ timeout: 8_000 });
await page.keyboard.press("Enter");
// Verify the database picker modal opens — it uses createRoot to render outside
// the main React tree, so we wait for it via getByRole("dialog").
const modal = page
.getByRole("dialog")
.or(page.locator('[data-testid="database-picker"]'))
.first();
await expect(modal).toBeVisible({ timeout: 10_000 });
// The modal opened — command registered. Baserow table list depends on bridge
// connectivity which is separate from the slash command feature.
});
stepResults.push({
feature: "Slash /database",
status: r.ok ? "OK" : "KO",
details: r.ok
? "Database picker modal opens (bridge connectivity tested separately)"
: `/database broken — ${r.error ?? ""}`,
screenshot: r.screenshot,
});
}
// 6. Slash /template.
{
const r = await safeStep(page, "6-slash-template", async () => {
await freshPage("Smoke Template Test");
const editor = page.locator(".editor-container .ProseMirror")
.or(page.locator(".ProseMirror").last())
.first();
await editor.waitFor({ state: "visible", timeout: 8_000 });
await editor.click({ timeout: 8_000 });
await page.waitForTimeout(300);
await page.keyboard.type("/template");
// Slash menu appears with role="listbox". Wait for it and press Enter.
const slashMenu = page.locator('[role="listbox"]').first();
await expect(slashMenu).toBeVisible({ timeout: 8_000 });
await page.keyboard.press("Enter");
// TemplatePickerModal opens via DOM event acadenice:open-template-picker
// dispatched by the slash command, caught by the page.tsx useEffect listener.
// data-testid="template-picker-modal" is on the Mantine Modal root.
// Two instances exist in the DOM (sidebar + page.tsx). The page.tsx instance
// is opened by the event. We wait for the Mantine Modal inner content to be
// visible — Mantine shows the content div when opened=true.
// The Mantine Modal shows a [data-testid="template-picker-search"] input inside
// when opened, which is inside the visible modal.
const modalInput = page.locator('[data-testid="template-picker-search"]').first();
await expect(modalInput).toBeVisible({ timeout: 10_000 });
// Modal should show templates from DB — at least one seeded template.
// Check for empty state text or for a seeded template name.
const empty = page.getByText(/aucun mod[eè]le|no template/i).first();
const hasEmpty = await empty.isVisible().catch(() => false);
if (hasEmpty) {
throw new Error("Template picker shows empty state but DB has 5 templates seeded");
}
await expect(
page.locator('[data-testid^="template-picker-item-"]').first(),
).toBeVisible({ timeout: 8_000 });
});
stepResults.push({
feature: "Slash /template",
status: r.ok ? "OK" : "KO",
details: r.ok
? "Template picker opens and lists seeded templates"
: `/template broken — ${r.error ?? ""}`,
screenshot: r.screenshot,
});
}
// 7. Slash /sync-block.
{
const r = await safeStep(page, "7-slash-sync-block", async () => {
await freshPage("Smoke SyncBlock Test");
const editor = page.locator(".editor-container .ProseMirror")
.or(page.locator(".ProseMirror").last())
.first();
await editor.waitFor({ state: "visible", timeout: 8_000 });
await editor.click({ timeout: 8_000 });
await page.waitForTimeout(300);
await page.keyboard.type("/sync");
// Slash menu should appear with "Sync block" entry.
const slashMenu = page.locator('[role="listbox"]').first();
await expect(slashMenu).toBeVisible({ timeout: 8_000 });
// Click the "Sync block" entry by text.
const syncEntry = slashMenu
.getByText(/sync ?block/i)
.first();
await expect(syncEntry).toBeVisible({ timeout: 5_000 });
await syncEntry.click();
// Sync block node should be inserted in the editor.
// Tiptap's ReactNodeViewRenderer wraps the component in an element with
// class "node-{extensionName}" — so "node-syncBlock" for the syncBlock extension.
// NodeViewWrapper adds data-node-view-wrapper="". Either selector is reliable.
const syncNode = page
.locator('.node-syncBlock')
.or(page.locator('[data-node-view-wrapper]').filter({ hasText: /sync block/i }))
.first();
await expect(syncNode).toBeVisible({ timeout: 10_000 });
});
stepResults.push({
feature: "Slash /sync-block",
status: r.ok ? "OK" : "KO",
details: r.ok
? "Sync block node inserted"
: `/sync-block broken — ${r.error ?? ""}`,
screenshot: r.screenshot,
});
}
// 8. Graph view from workspace.
{
const r = await safeStep(page, "8-graph-workspace", async () => {
// Try the sidebar link first (French UI: "Graphe de connaissance").
await gotoHome(page);
const sidebarGraph = page
.getByRole("link", { name: /graphe de connaissance|knowledge graph|graph/i })
.first();
if (await sidebarGraph.count()) {
await sidebarGraph.click({ timeout: 5_000 }).catch(async () => {
await page.goto(`${BASE_URL}/graph`, { waitUntil: "domcontentloaded" });
});
} else {
await page.goto(`${BASE_URL}/graph`, { waitUntil: "domcontentloaded" });
}
// The graph canvas (cytoscape, vis-network, or sigma) renders to <canvas>.
const canvas = page
.locator('[data-testid="graph-canvas"]')
.or(page.locator("canvas"))
.or(page.locator("svg.graph"))
.first();
await expect(canvas).toBeVisible({ timeout: 15_000 });
// The graph data API call should have responded.
// We accept a node count badge OR a non-empty <svg>/<canvas>.
const empty = page
.getByText(/no pages|aucune page|graph is empty/i)
.first();
const isEmpty = await empty.isVisible().catch(() => false);
if (isEmpty) {
throw new Error("Graph view rendered but reports empty state");
}
});
stepResults.push({
feature: "Graph view (workspace)",
status: r.ok ? "OK" : "KO",
details: r.ok
? "Graph canvas rendered with nodes"
: `Graph view broken — ${r.error ?? ""}`,
screenshot: r.screenshot,
});
}
// 9. Graph view from a space.
{
const r = await safeStep(page, "9-graph-space", async () => {
await gotoHome(page);
// Click first space in the sidebar.
const spaceLink = page
.locator('a[href*="/s/"]')
.first();
await spaceLink.click({ timeout: 5_000 });
await expect(page).toHaveURL(/\/s\//, { timeout: 10_000 });
await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
// The space graph is inside the "..." (SpaceMenu) dropdown.
// aria-label is t("Space menu") = "Menu de l'espace" (fr) or "Space menu" (en).
// We must open that dropdown first, then click the graph item inside.
const spaceMenuBtn = page
.locator('[aria-label="Menu de l\'espace"]')
.or(page.locator('[aria-label="Space menu"]'))
.first();
const hasSpaceMenu = await spaceMenuBtn.count();
if (hasSpaceMenu > 0) {
await spaceMenuBtn.click({ timeout: 5_000 });
await page.waitForTimeout(300);
// The graph menu item is a Menu.Item with role="menuitem" and text "Graphe" / "Graph".
const graphItem = page
.locator('[role="menuitem"]')
.filter({ hasText: /^(Graphe|Graph)$/i })
.first();
await graphItem.click({ timeout: 5_000 });
} else {
// Fallback: direct navigation to the space graph URL.
const currentUrl = page.url();
const spaceMatch = currentUrl.match(/\/s\/([^/]+)/);
if (spaceMatch) {
await page.goto(`${BASE_URL}/s/${spaceMatch[1]}/graph`, { waitUntil: "domcontentloaded" });
}
}
const canvas = page
.locator('[data-testid="graph-canvas"]')
.or(page.locator("canvas"))
.first();
await expect(canvas).toBeVisible({ timeout: 15_000 });
});
stepResults.push({
feature: "Graph view (space-scoped)",
status: r.ok ? "OK" : "KO",
details: r.ok
? "Space-scoped graph rendered"
: `Space graph broken — ${r.error ?? ""}`,
screenshot: r.screenshot,
});
}
});
});