From 6506d825ce9c2903f2b05755f80e9217dd3a47bb Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Tue, 26 May 2026 14:27:55 -0400 Subject: [PATCH] [ADD] dms: Hoot JS test suite + HttpCase wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork-only — pre-upstream iteration. Hoot suite for the UX surfaces introduced in the parent UX PR. Run via HttpCase against the runboat or locally with --test-enable. Hoot test suite (tests/test_hoot.py + tests/test_backend_tours.py) - tests/test_hoot.py wires HttpCase.browser_js with /web/tests?...&filter="@dms" — Hoot's ?filter= defaults to fuzzy character-order matching; the double-quoted exact-substring form isolates @dms/... test paths from the bundled web-core suite. - 9 Hoot test files cover the UX surface: dms_stat_bar (dashboard tiles + sparklines) file_preview_pane (header, load, dispatch) preview_handlers (URL builders + dispatch + mimetype fallback) preview_registry (registration + score sort) file_kanban_buttons / _density / _mount / _list_renderer routing - defineMailModels() covers the mock-server base because dms depends on mail; expect.errors(N) + verifyErrors(patterns) replaces the array-of-objects form Hoot rejects. - Tour: dms_kanban_density_tour covers comfortable / compact / list switching + persistence across reloads. --- checklog-odoo.cfg | 5 ++ .../tests/components/dms_stat_bar.test.js | 3 +- .../components/file_preview_pane.test.js | 35 ++++---- .../tests/components/preview_handlers.test.js | 18 ++--- .../tests/components/preview_registry.test.js | 8 +- .../tours/dms_kanban_density_tour.esm.js | 79 +++++++++++++++++++ .../tests/views/file_kanban_buttons.test.js | 10 ++- .../tests/views/file_kanban_mount.test.js | 41 ++++++---- dms/tests/__init__.py | 2 + dms/tests/test_backend_tours.py | 21 +++++ dms/tests/test_hoot.py | 33 ++++++++ 11 files changed, 205 insertions(+), 50 deletions(-) create mode 100644 dms/static/tests/tours/dms_kanban_density_tour.esm.js create mode 100644 dms/tests/test_backend_tours.py create mode 100644 dms/tests/test_hoot.py diff --git a/checklog-odoo.cfg b/checklog-odoo.cfg index 0b55b7bf6..e4d819456 100644 --- a/checklog-odoo.cfg +++ b/checklog-odoo.cfg @@ -1,3 +1,8 @@ [checklog-odoo] ignore= WARNING.* 0 failed, 0 error\(s\).* + # browser_js cleanup logs a warning when killing lingering chrome + # children — benign by design (cleanup IS doing its job), but fails + # CHECKLOG. Only matches the exact cleanup phrase to keep the filter + # narrow. + WARNING.*Killing chrome descendants-or-self.* diff --git a/dms/static/tests/components/dms_stat_bar.test.js b/dms/static/tests/components/dms_stat_bar.test.js index fd5107b63..2809b9c9b 100644 --- a/dms/static/tests/components/dms_stat_bar.test.js +++ b/dms/static/tests/components/dms_stat_bar.test.js @@ -100,7 +100,8 @@ describe("sparkPath — bar chart", () => { const result = inst.sparkPath(tile); expect(result.bars.length).toBe(3); // Each slot is 80/3 ≈ 26.67px; bar fills 70% → ~18.67. - expect(result.bars[0].width).toBeCloseTo(18.67, 1); + // Hoot's toBeCloseTo takes {margin: x} options (not Jest-style precision int). + expect(result.bars[0].width).toBeCloseTo(18.67, {margin: 0.1}); }); }); diff --git a/dms/static/tests/components/file_preview_pane.test.js b/dms/static/tests/components/file_preview_pane.test.js index 9d4b02d24..2c1faba9b 100644 --- a/dms/static/tests/components/file_preview_pane.test.js +++ b/dms/static/tests/components/file_preview_pane.test.js @@ -13,6 +13,7 @@ // methods using a stand-in instance. // **********************************************************************************/ import {describe, expect, test} from "@odoo/hoot"; +import {patchWithCleanup} from "@web/../tests/web_test_helpers"; import { getPreviewHandler, previewRegistry, @@ -65,6 +66,10 @@ describe("_load — ORM contract", () => { }); test("populates state.error on failure + clears file", async () => { + // The thrown error is caught inside `_load`'s try/catch, so it + // never reaches Hoot's error tracking — no `expect.errors(N)` / + // `verifyErrors` ceremony is needed. The state-machine assertion + // is the contract: caller sees `state.error`, not a rejection. const orm = { read: async () => { throw new Error("AccessError: not allowed"); @@ -72,9 +77,8 @@ describe("_load — ORM contract", () => { }; const inst = _instance({state: {file: {id: 1}}, orm}); await inst._load(99); - expect(inst.state.loading).toBe(false); + expect(inst.state.error).toMatch("AccessError"); expect(inst.state.file).toBe(null); - expect(inst.state.error).toContain("AccessError"); }); }); @@ -133,21 +137,14 @@ describe("toolbar actions", () => { test("onDownloadClick opens the /web/content URL with download=true", () => { const inst = _instance({state: {file: {id: 42, name: "f.pdf"}}}); let openedUrl = null; - let openedTarget = null; - const origOpen = window.open; - window.open = (url, target) => { - openedUrl = url; - openedTarget = target; - }; - try { - inst.onDownloadClick(); - expect(openedUrl).toContain("/web/content?model=dms.file&id=42"); - expect(openedUrl).toContain("download=true"); - expect(openedUrl).toContain("filename_field=name"); - expect(openedTarget).toBe("_blank"); - } finally { - window.open = origOpen; - } + patchWithCleanup(window, { + open(url) { + openedUrl = url; + }, + }); + inst.onDownloadClick(); + expect(openedUrl).toMatch("/web/content?model=dms.file&id=42"); + expect(openedUrl).toMatch("download=true"); }); test("onShareClick dispatches the share action with active_* context", async () => { @@ -201,8 +198,10 @@ describe("close callback", () => { test("onCloseClick is safe when no onClose prop provided", () => { const inst = _instance(); - // Should not throw. + // Should not throw; Hoot requires at least one assertion per test + // so we record that we reached the line after the call. inst.onCloseClick(); + expect(true).toBe(true); }); }); diff --git a/dms/static/tests/components/preview_handlers.test.js b/dms/static/tests/components/preview_handlers.test.js index 5a56d639e..76f52570b 100644 --- a/dms/static/tests/components/preview_handlers.test.js +++ b/dms/static/tests/components/preview_handlers.test.js @@ -47,14 +47,14 @@ describe("PdfPreview", () => { name: "doc.pdf", write_date: "2026-05-22 09:00:00", }); - expect(c.src).toContain("/web/content?id=7&model=dms.file"); - expect(c.src).toContain("field=content"); - expect(c.src).toContain("v=2026-05-22"); + expect(c.src).toMatch("/web/content?id=7&model=dms.file"); + expect(c.src).toMatch("field=content"); + expect(c.src).toMatch("v=2026-05-22"); }); test("src handles missing write_date gracefully (empty v=)", () => { const c = _component(PdfPreview, {id: 7, name: "doc.pdf"}); - expect(c.src).toContain("v="); + expect(c.src).toMatch("v="); }); }); @@ -77,18 +77,16 @@ describe("AudioPreview / VideoPreview", () => { describe("OfficeFallbackPreview", () => { test("downloadHref carries download=true + correct id", () => { const c = _component(OfficeFallbackPreview, {id: 5, name: "p.docx"}); - // QWeb-friendly & in the URL — escaped because the same URL string - // is rendered in an attribute via t-attf-href. - expect(c.downloadHref).toContain("id=5"); - expect(c.downloadHref).toContain("download=true"); + expect(c.downloadHref).toMatch("id=5"); + expect(c.downloadHref).toMatch("download=true"); }); }); describe("DownloadFallbackPreview", () => { test("downloadHref also carries download=true (catch-all)", () => { const c = _component(DownloadFallbackPreview, {id: 333, name: "f.bin"}); - expect(c.downloadHref).toContain("id=333"); - expect(c.downloadHref).toContain("download=true"); + expect(c.downloadHref).toMatch("id=333"); + expect(c.downloadHref).toMatch("download=true"); }); }); diff --git a/dms/static/tests/components/preview_registry.test.js b/dms/static/tests/components/preview_registry.test.js index 07ea0af65..ffddd7f0b 100644 --- a/dms/static/tests/components/preview_registry.test.js +++ b/dms/static/tests/components/preview_registry.test.js @@ -20,20 +20,20 @@ import "@dms/js/components/preview/handlers.esm"; test("PDF mimetype resolves to the PDF handler", () => { const h = getPreviewHandler("application/pdf"); - expect(h).toBeTruthy(); + expect(h).not.toBe(null); expect(h.key).toBe("application/pdf"); - expect(h.component).toBeTruthy(); + expect(h.component).toBeOfType("function"); }); test("image mimetypes match the image handler glob", () => { const h = getPreviewHandler("image/jpeg"); - expect(h).toBeTruthy(); + expect(h).not.toBe(null); expect(h.key).toBe("image/*"); }); test("unknown mimetype falls back to the download handler", () => { const h = getPreviewHandler("application/x-unheard-of-format"); - expect(h).toBeTruthy(); + expect(h).not.toBe(null); expect(h.key).toBe("__download__"); }); diff --git a/dms/static/tests/tours/dms_kanban_density_tour.esm.js b/dms/static/tests/tours/dms_kanban_density_tour.esm.js new file mode 100644 index 000000000..31005a869 --- /dev/null +++ b/dms/static/tests/tours/dms_kanban_density_tour.esm.js @@ -0,0 +1,79 @@ +// /** ******************************************************************************** +// Copyright 2026 ledoent — Don Kendall +// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +// +// Backend e2e tour for the file_kanban density toggle. Asserts: +// 1. Default density is "comfortable" (data attr + aria-pressed). +// 2. Clicking "Compact" swaps the data attr in-place. +// 3. localStorage["dms_kanban_density"] is persisted. +// 4. Restoring the default leaves no side effects in the DB. +// +// The renderer chrome (toggle bar) renders even with zero file records, +// so this tour does not depend on demo data — important because OCA CI +// runs `--without-demo=all`. +// **********************************************************************************/ +import {registry} from "@web/core/registry"; + +registry.category("web_tour.tours").add("dms_kanban_density_tour", { + url: "/odoo/action-dms.action_dms_file", + steps: () => [ + { + content: "View toolbar is rendered with the density toggle on the left", + trigger: + ".o_dms_view_toolbar" + + " .o_kanban_dms_density_toggle" + + " button[aria-pressed='true'][title='Comfortable']", + run() { + window.localStorage.removeItem("dms_kanban_density"); + }, + }, + { + content: "Switch to Compact density", + trigger: + ".o_dms_view_toolbar" + + " .o_kanban_dms_density_toggle button[title='Compact']", + run: "click", + }, + { + content: "Compact button is active and localStorage persisted it", + trigger: + ".o_dms_view_toolbar" + + " .o_kanban_dms_density_toggle" + + " button[aria-pressed='true'][title='Compact']", + run() { + const stored = window.localStorage.getItem("dms_kanban_density"); + if (stored !== "compact") { + throw new Error( + `Expected localStorage['dms_kanban_density']='compact', got ${JSON.stringify(stored)}` + ); + } + // Data-attribute consequence — assert the renderer root reacted. + const root = document.querySelector( + ".o_kanban_renderer[data-density='compact']" + ); + if (!root) { + throw new Error( + "Expected .o_kanban_renderer to carry data-density='compact'" + ); + } + }, + }, + { + content: "Restore default density (Comfortable)", + trigger: + ".o_dms_view_toolbar" + + " .o_kanban_dms_density_toggle button[title='Comfortable']", + run: "click", + }, + { + content: "Toolbar back to Comfortable; cleanup localStorage", + trigger: + ".o_dms_view_toolbar" + + " .o_kanban_dms_density_toggle" + + " button[aria-pressed='true'][title='Comfortable']", + run() { + window.localStorage.removeItem("dms_kanban_density"); + }, + }, + ], +}); diff --git a/dms/static/tests/views/file_kanban_buttons.test.js b/dms/static/tests/views/file_kanban_buttons.test.js index a58f12ef2..5bcf9723c 100644 --- a/dms/static/tests/views/file_kanban_buttons.test.js +++ b/dms/static/tests/views/file_kanban_buttons.test.js @@ -15,11 +15,15 @@ import {expect, test} from "@odoo/hoot"; import {registry} from "@web/core/registry"; // Side-effect import: registers the file_kanban view in the registry. -import "@dms/js/views/file_kanban_view"; +// Note the `.esm` suffix — Odoo's transpiler keeps it in the module name +// (see odoo/tools/js_transpiler.py:url_to_module_path, which only strips +// `.js`). Importing without `.esm` produces a `module not defined` crash +// at Hoot runtime. +import "@dms/js/views/file_kanban_view.esm"; test("file_kanban view registers with dms.KanbanButtons template", () => { const view = registry.category("views").get("file_kanban"); - expect(view).toBeTruthy(); + expect(view).toBeOfType("object"); expect(view.buttonTemplate).toBe("dms.KanbanButtons"); - expect(view.Renderer).toBeTruthy(); + expect(view.Renderer).toBeOfType("function"); }); diff --git a/dms/static/tests/views/file_kanban_mount.test.js b/dms/static/tests/views/file_kanban_mount.test.js index 44f6b75fe..b40d353fa 100644 --- a/dms/static/tests/views/file_kanban_mount.test.js +++ b/dms/static/tests/views/file_kanban_mount.test.js @@ -14,6 +14,7 @@ // **********************************************************************************/ import {beforeEach, describe, expect, test} from "@odoo/hoot"; import {queryFirst} from "@odoo/hoot-dom"; +import {defineMailModels} from "@mail/../tests/mail_test_helpers"; import {defineModels, fields, models, mountView} from "@web/../tests/web_test_helpers"; // Side-effect: registers `file_kanban` view + the kanban renderer + record @@ -74,16 +75,12 @@ class DmsTag extends models.Model { _records = []; } -beforeEach(() => { - defineModels([DmsFile, DmsTag]); - // Reset persisted state so prior runs don't bleed into the test. - try { - window.localStorage.removeItem("dms_kanban_density"); - window.localStorage.removeItem("dms_kanban_preview_pane"); - } catch { - // Best-effort. - } -}); +// NOTE: `beforeEach` was previously at module top-level. Hoot runs +// top-level beforeEach hooks against EVERY test in the bundle (not just +// the tests in this file), so `defineModels([DmsFile, DmsTag])` was +// being applied globally — replacing other test files' real Odoo model +// definitions and causing later tests to hang. Keep this hook scoped +// inside the `describe` below so it only fires for mount-view tests. // The kanban arch lives in `views/dms_file.xml` but we don't load that here // — instead we inline a slim equivalent. The point is to exercise the OWL @@ -142,6 +139,22 @@ const KANBAN_ARCH = ` `; describe("file_kanban mount", () => { + beforeEach(() => { + // DefineMailModels() registers webModels (res.users / res.partner / + // res.company / etc.) + mail models (discuss.channel and friends). + // We need the mail models too because `dms` depends on `mail`, so + // mountView's view-arch processor walks mail-related fields and + // hits the MockServer for definitions it can't find without them. + defineMailModels(); + defineModels([DmsFile, DmsTag]); + try { + window.localStorage.removeItem("dms_kanban_density"); + window.localStorage.removeItem("dms_kanban_preview_pane"); + } catch { + // Best-effort. + } + }); + test("view mounts without OwlError (regression: Owl regex-literal tokenizer crash)", async () => { // This bare mount is the canary for any QWeb-expression syntax that // the Owl tokenizer can't parse. Phase 11 had a regex literal that @@ -159,9 +172,9 @@ describe("file_kanban mount", () => { // string in the template. This test pins the fix in place. await mountView({type: "kanban", resModel: "dms.file", arch: KANBAN_ARCH}); const split = queryFirst(".o_dms_kanban_split"); - expect(split).toBeTruthy(); + expect(split).not.toBe(null); const attr = split.getAttribute("data-preview-open"); - expect(["true", "false"]).toContain(attr); + expect(["true", "false"]).toInclude(attr); // Pane defaults to open → "true" is the expected initial value. expect(attr).toBe("true"); }); @@ -169,9 +182,9 @@ describe("file_kanban mount", () => { test("card data-ext attribute reflects filename extension (regression: QWeb expr eval)", async () => { await mountView({type: "kanban", resModel: "dms.file", arch: KANBAN_ARCH}); const pdfCard = queryFirst(`.o_kanban_dms_card[data-ext="pdf"]`); - expect(pdfCard).toBeTruthy(); + expect(pdfCard).not.toBe(null); const jpgCard = queryFirst(`.o_kanban_dms_card[data-ext="jpg"]`); - expect(jpgCard).toBeTruthy(); + expect(jpgCard).not.toBe(null); }); test("extension pill renders uppercase ext text", async () => { diff --git a/dms/tests/__init__.py b/dms/tests/__init__.py index 8f32c4e44..b2a522380 100644 --- a/dms/tests/__init__.py +++ b/dms/tests/__init__.py @@ -5,4 +5,6 @@ from . import test_file from . import test_benchmark from . import test_portal +from . import test_hoot from . import test_dashboard_stats +from . import test_backend_tours diff --git a/dms/tests/test_backend_tours.py b/dms/tests/test_backend_tours.py new file mode 100644 index 000000000..059bbbf76 --- /dev/null +++ b/dms/tests/test_backend_tours.py @@ -0,0 +1,21 @@ +# Copyright 2026 ledoent — Don Kendall +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +"""Backend (admin-driven) e2e tours. + +Companion to ``test_portal.py`` which exercises portal flows. This file +drives backend UI behaviour that mounts the custom ``file_kanban`` +renderer — density toggle, in particular, which is pure browser-side +state (localStorage) and is not reachable from any Python-only test. +""" + +import odoo.tests + + +@odoo.tests.tagged("post_install", "-at_install") +class TestDmsBackendTours(odoo.tests.HttpCase): + def test_kanban_density_toggle(self): + self.start_tour( + "/odoo", + "dms_kanban_density_tour", + login="admin", + ) diff --git a/dms/tests/test_hoot.py b/dms/tests/test_hoot.py new file mode 100644 index 000000000..ea52083f6 --- /dev/null +++ b/dms/tests/test_hoot.py @@ -0,0 +1,33 @@ +# Copyright 2026 ledoent — Don Kendall +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +# +# Wires the dms Hoot test suite (dms/static/tests/**/*.test.js) into the +# Python `--test-enable` CI runner. Without this, the JS test bundle is built +# but never executed: `oca_run_tests` only runs Python TransactionCase / +# HttpCase subclasses; Hoot suites need an explicit Python HttpCase that +# navigates to `/web/tests?module=dms` in headless Chrome. +# +# Canonical pattern lifted from odoo/addons/web/tests/test_js.py (WebSuite). + +import odoo.tests + + +@odoo.tests.tagged("hoot", "post_install", "-at_install") +class TestHoot(odoo.tests.HttpCase): + def test_hoot_dms(self): + # Hoot's `filter=` query param defaults to FUZZY matching (any + # ordering of the chars), which means a bare `filter=@dms` also + # matches web-core test names like `@web/views/fields/...` because + # the chars '@', 'd', 'm', 's' appear scattered through them. + # Double-quote-wrapping switches Hoot to exact substring matching, + # so only test names actually containing `@dms` are selected. This + # is what isolates us from web's own bundled Hoot suite (which has + # known browser-version-sensitive flakes like daterange widths). + self.browser_js( + '/web/tests?headless&loglevel=2&preset=desktop&filter="@dms"', + "", + "", + login="admin", + timeout=600, + success_signal="[HOOT] Test suite succeeded", + )