Adds an autonomous Playwright suite (acadenice-smoke-full.spec.ts) that drives the prod-like AcadeDoc stack via the UI to surface UI bugs without manual screenshot diagnostic. Each step is wrapped in test.step and records network/console/page errors plus per-step screenshots. A post-test script (scripts/generate-smoke-report.ts) aggregates telemetry into SMOKE-REPORT.md. The smoke runs against the real fork stack (client :5173, server :3001, bridge :4000) using Corentin's prod-like Acadenice credentials. It does NOT share state with the cross-stack e2e suite (no Baserow seeding, no docker-compose-e2e dependency) — its dedicated playwright.smoke.config.ts declares no setup project. Coverage: login, create page, sub-page nesting, wikilink + backlink, slash /database, slash /template, slash /sync-block, workspace graph, space graph. Initial run results (baseline): 2 OK / 7 KO / 0 PARTIAL — confirms SpaceSidebar renders-more-hooks crash when opening "Nouvelle page" submenu (blank-screen Error Boundary), GET /api/acadenice/templates → 403, and space-scoped graph entry point not reachable. Captured in SMOKE-REPORT.md for follow-up by R4.5/R4.6.
608 lines
21 KiB
TypeScript
608 lines
21 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. Tries several known
|
|
* Docmost selectors (the icon button in the space sidebar header, the
|
|
* keyboard shortcut, then a fallback: keyboard "Ctrl+Alt+N" if available).
|
|
*/
|
|
async function clickCreatePage(page: Page): Promise<void> {
|
|
// Locator strategies, in order of specificity.
|
|
// 1. Mantine ActionIcon with title attribute (Docmost convention).
|
|
// 2. Plus icon next to the space name in the sidebar tree header.
|
|
// 3. Generic "+" button.
|
|
// Step 1: open the "Nouvelle page" sidebar dropdown.
|
|
// The trigger appears in the space sidebar; clicking it reveals two options:
|
|
// - "Nouvelle page" (blank)
|
|
// - "Depuis un modele" (from template)
|
|
const trigger = page.getByText(/^Nouvelle page$/i).first();
|
|
if (await trigger.count()) {
|
|
await trigger.click({ timeout: 5_000 });
|
|
// Step 2: pick the blank-page option from the popup.
|
|
const blankOption = page
|
|
.getByRole("menuitem", { name: /^nouvelle page$/i })
|
|
.or(page.locator('[role="menu"] >> text="Nouvelle page"'))
|
|
.first();
|
|
if (await blankOption.count()) {
|
|
await blankOption.click({ timeout: 3_000 }).catch(() => {});
|
|
}
|
|
try {
|
|
await page.waitForURL(/\/p\//, { timeout: 5_000 });
|
|
return;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
|
|
// Fallback: the "+" icon button next to the "Pages" tree section header.
|
|
const plusBtn = page
|
|
.locator('button[aria-label*="create" i]')
|
|
.or(page.locator('button[title*="page" i]'))
|
|
.first();
|
|
if (await plusBtn.count()) {
|
|
await plusBtn.click({ timeout: 3_000 }).catch(() => {});
|
|
try {
|
|
await page.waitForURL(/\/p\//, { timeout: 5_000 });
|
|
return;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
|
|
// Last resort: keyboard shortcut Ctrl+Alt+N (Docmost default).
|
|
await page.keyboard.press("Control+Alt+n");
|
|
await page.waitForURL(/\/p\//, { timeout: 5_000 });
|
|
}
|
|
|
|
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);
|
|
const titleInput = page
|
|
.locator('[data-testid="page-title"]')
|
|
.or(page.getByPlaceholder(/untitled|sans titre|title/i))
|
|
.or(page.locator('input[type="text"]').first())
|
|
.first();
|
|
await titleInput.waitFor({ state: "visible", timeout: 10_000 });
|
|
await titleInput.fill("Smoke Page A");
|
|
await titleInput.press("Tab");
|
|
await expect(page).toHaveURL(/\/p\//, { timeout: 10_000 });
|
|
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" });
|
|
// The "Add sub-page" lives in the sidebar tree row hover menu.
|
|
// Hover the parent node row first.
|
|
const parentRow = page
|
|
.locator(`text="Smoke Page A"`)
|
|
.first();
|
|
await parentRow.hover({ timeout: 5_000 });
|
|
const addSub = page
|
|
.getByRole("button", { name: /add sub-?page|sous-page|new sub/i })
|
|
.or(page.locator('[data-testid="add-subpage"]'))
|
|
.first();
|
|
await addSub.click({ timeout: 5_000 });
|
|
const titleInput = page
|
|
.locator('[data-testid="page-title"]')
|
|
.or(page.getByPlaceholder(/untitled|sans titre|title/i))
|
|
.first();
|
|
await titleInput.waitFor({ state: "visible", timeout: 10_000 });
|
|
await titleInput.fill("Smoke Sub-page A.1");
|
|
await titleInput.press("Tab");
|
|
// Verify the sub-page appears NESTED under Smoke Page A in the sidebar.
|
|
// The aria tree typically nests children under their parent's <li>.
|
|
const sidebar = page
|
|
.getByRole("tree")
|
|
.or(page.locator('[data-testid="sidebar-tree"]'))
|
|
.first();
|
|
const parentNode = sidebar.locator(
|
|
'li:has-text("Smoke Page A")',
|
|
).first();
|
|
await expect(
|
|
parentNode.locator('text="Smoke Sub-page A.1"'),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
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" });
|
|
const editor = page
|
|
.locator('[contenteditable="true"]')
|
|
.or(page.locator(".ProseMirror"))
|
|
.first();
|
|
await editor.click({ timeout: 5_000 });
|
|
await page.keyboard.type("[[Smoke Sub-page A.1");
|
|
// Suggestion popup should show the matching page.
|
|
const suggestion = page
|
|
.locator('[data-testid="mention-list"]')
|
|
.or(page.locator('[role="listbox"]'))
|
|
.or(page.locator(".tippy-box"))
|
|
.first();
|
|
await expect(suggestion).toBeVisible({ timeout: 5_000 });
|
|
await page.keyboard.press("Enter");
|
|
// Closing brackets may auto-insert; type them anyway just in case.
|
|
await page.keyboard.type("]] ");
|
|
await page.waitForTimeout(2_000); // debounce save
|
|
// Navigate to A.1 and check backlinks panel.
|
|
const subLink = page.locator('text="Smoke Sub-page A.1"').first();
|
|
await subLink.click({ timeout: 5_000 });
|
|
await expect(page).toHaveURL(/\/p\//);
|
|
const backlinks = page
|
|
.locator('[data-testid="backlinks"]')
|
|
.or(page.getByText(/backlinks?|liens entrants/i))
|
|
.first();
|
|
await expect(backlinks).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
backlinks.locator('text="Smoke Page A"'),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
stepResults.push({
|
|
feature: "Wikilink + backlink",
|
|
status: r.ok ? "OK" : "KO",
|
|
details: r.ok
|
|
? "Wikilink suggestion + backlink panel show correctly"
|
|
: `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" });
|
|
} else {
|
|
await enterFirstSpace(page);
|
|
}
|
|
await clickCreatePage(page);
|
|
const titleInput = page
|
|
.locator('[data-testid="page-title"]')
|
|
.or(page.getByPlaceholder(/untitled|sans titre|title/i))
|
|
.or(page.locator('input[type="text"]').first())
|
|
.first();
|
|
await titleInput.waitFor({ state: "visible", timeout: 10_000 });
|
|
await titleInput.fill(title);
|
|
await titleInput.press("Tab");
|
|
await expect(page).toHaveURL(/\/p\//, { timeout: 10_000 });
|
|
}
|
|
|
|
// 5. Slash /database.
|
|
{
|
|
const r = await safeStep(page, "5-slash-database", async () => {
|
|
await freshPage("Smoke Database Test");
|
|
const editor = page.locator(".ProseMirror").first();
|
|
await editor.click({ timeout: 5_000 });
|
|
// Track if the page reloads (Corentin's reported bug).
|
|
let reloaded = false;
|
|
page.once("framenavigated", () => {
|
|
reloaded = true;
|
|
});
|
|
await page.keyboard.type("/database");
|
|
// Slash menu should appear.
|
|
const slashMenu = page
|
|
.locator('[data-testid="slash-menu"]')
|
|
.or(page.locator('[role="listbox"]'))
|
|
.or(page.locator(".tippy-box"))
|
|
.first();
|
|
await expect(slashMenu).toBeVisible({ timeout: 5_000 });
|
|
await page.keyboard.press("Enter");
|
|
// The Acadenice database picker modal should open.
|
|
const modal = page
|
|
.getByRole("dialog")
|
|
.or(page.locator('[data-testid="database-picker"]'))
|
|
.first();
|
|
await expect(modal).toBeVisible({ timeout: 8_000 });
|
|
// The modal should list at least one Baserow table (personne, formation, bloc...).
|
|
await expect(
|
|
modal.getByText(/personne|formation|bloc/i).first(),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
if (reloaded) {
|
|
throw new Error("Page reloaded during /database — crash bug confirmed");
|
|
}
|
|
});
|
|
stepResults.push({
|
|
feature: "Slash /database",
|
|
status: r.ok ? "OK" : "KO",
|
|
details: r.ok
|
|
? "Database picker modal opens with Baserow tables listed"
|
|
: `/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(".ProseMirror").first();
|
|
await editor.click({ timeout: 5_000 });
|
|
await page.keyboard.type("/template");
|
|
const slashMenu = page
|
|
.locator('[data-testid="slash-menu"]')
|
|
.or(page.locator('[role="listbox"]'))
|
|
.or(page.locator(".tippy-box"))
|
|
.first();
|
|
await expect(slashMenu).toBeVisible({ timeout: 5_000 });
|
|
await page.keyboard.press("Enter");
|
|
const modal = page
|
|
.getByRole("dialog")
|
|
.or(page.locator('[data-testid="template-picker"]'))
|
|
.first();
|
|
await expect(modal).toBeVisible({ timeout: 8_000 });
|
|
// Modal must list the 5 seeded templates — assert on a known one.
|
|
// If "aucun modele" / "no template" is shown, the picker is empty (bug).
|
|
const empty = modal
|
|
.getByText(/aucun mod[èe]le|no template|empty/i)
|
|
.first();
|
|
const hasEmpty = await empty.isVisible().catch(() => false);
|
|
if (hasEmpty) {
|
|
throw new Error(
|
|
"Template picker shows empty state but DB has 5 templates",
|
|
);
|
|
}
|
|
await expect(
|
|
modal.getByText(/daily|standup|meeting|note/i).first(),
|
|
).toBeVisible({ timeout: 8_000 });
|
|
});
|
|
stepResults.push({
|
|
feature: "Slash /template",
|
|
status: r.ok ? "OK" : "KO",
|
|
details: r.ok
|
|
? "Template picker lists templates from DB"
|
|
: `/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(".ProseMirror").first();
|
|
await editor.click({ timeout: 5_000 });
|
|
await page.keyboard.type("/sync");
|
|
const slashMenu = page
|
|
.locator('[data-testid="slash-menu"]')
|
|
.or(page.locator('[role="listbox"]'))
|
|
.or(page.locator(".tippy-box"))
|
|
.first();
|
|
await expect(slashMenu).toBeVisible({ timeout: 5_000 });
|
|
// Look for "sync block" entry.
|
|
const syncEntry = slashMenu
|
|
.getByText(/sync ?block|bloc synchronis/i)
|
|
.first();
|
|
await expect(syncEntry).toBeVisible({ timeout: 5_000 });
|
|
await syncEntry.click();
|
|
// Sync block node should be inserted in the editor.
|
|
const syncNode = page
|
|
.locator('[data-type="sync-block"]')
|
|
.or(page.locator('[data-testid="sync-block"]'))
|
|
.first();
|
|
await expect(syncNode).toBeVisible({ timeout: 8_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 });
|
|
// Open the space-level graph (button in space header or sidebar).
|
|
const graphBtn = page
|
|
.getByRole("link", { name: /graph|graphe/i })
|
|
.or(page.getByRole("button", { name: /graph|graphe/i }))
|
|
.first();
|
|
await graphBtn.click({ timeout: 5_000 });
|
|
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,
|
|
});
|
|
}
|
|
});
|
|
});
|