/** * 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, ): 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 { 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 { 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 { // 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 . // 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 { 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 . 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 . 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 /. 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, }); } }); });