Skip to content
Merged
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
67 changes: 66 additions & 1 deletion e2e/tests/01-spp-starter-spmis.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@
// (200_individuals.xlsx) and confirms the pager reflects the new count
// 23 - Imports 200 group registrants in bulk via the Import records wizard
// (200_groups.xlsx) and confirms the pager reflects the new count
// 24 - Opens the backend with the test assets loaded (?debug=tests) and confirms Odoo's
// JavaScript module loader reports no errors, since one bad import in any installed
// module's web.assets_tests fails every backend tour
//
// All tests run in order and share a single browser session (test.describe.serial).
// A fresh Docker stack is spun up in beforeAll so every run starts from a clean database.

import {test, expect, Page, Browser} from "@playwright/test";
import {test, expect, Page, Browser, ConsoleMessage} from "@playwright/test";
import {resetStack} from "./helpers";
import * as path from "path";

Expand Down Expand Up @@ -1374,4 +1377,66 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => {
await logout(page);
console.log("✅ Admin logged out");
});

test("24 - backend test assets load without JavaScript module errors", async () => {
// Odoo's module loader (web/static/src/module_loader.js) logs these console
// errors when a module imports something no loaded file defines, e.g. an
// import path from an older Odoo version. The tour runner fails on any
// console error, so one bad import in web.assets_tests breaks every tour.
const moduleLoaderError =
/needed by other modules but have not been defined|failed to load because of an error|form a dependency cycle|have unmet dependencies/;
const loaderErrors: Promise<string>[] = [];
const onConsole = (msg: ConsoleMessage) => {
if (msg.type() === "error" && moduleLoaderError.test(msg.text())) {
// The module names are in the array argument, not in msg.text().
loaderErrors.push(
Promise.all(msg.args().map((arg) => arg.jsonValue())).then((args) =>
JSON.stringify(args)
)
);
}
};
// An uncaught exception from a test asset (a script that throws outside
// odoo.define) also fails the tour runner, without a loader message.
const pageErrors: string[] = [];
const onPageError = (error: Error) => {
pageErrors.push(error.message);
};

await login(page);
console.log("✅ Logged in as admin");

page.on("console", onConsole);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this only listens for console messages matching the four loader patterns. An uncaught exception from a test asset (a script that throws outside odoo.define, or a factory whose failure the loader doesn't report later) also fails the tour runner, and this test would still pass. Also collecting page.on("pageerror") and asserting that list is empty closes the gap for about two lines.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e6beb9a3. Test 24 now collects page.on("pageerror") and asserts expect(pageErrors).toEqual([]), and detaches
the listener in the finally. For the red check I temporarily added
setTimeout(() => { throw new Error("pageerror-probe"); }) to the tour file, so the throw happened outside anything the
loader reports. The test failed with + "pageerror-probe". Without the probe, e2e 01 and 24 pass.

page.on("pageerror", onPageError);
try {
await page.goto("/odoo?debug=tests");
await expect(page.locator(".o_main_navbar")).toBeVisible({timeout: 30_000});
// Not "networkidle": the bus websocket worker request never finishes.
// Wait until the loader has defined a module from web.assets_tests and
// run its error check, which logs any errors synchronously.
await page.waitForFunction(
() => {
const loader = (window as any).odoo.loader;
return (
loader.factories.has("@web/../tests/legacy/utils") &&
loader.checkErrorProm === null
);
},
null,
{timeout: 30_000}
);

expect(await Promise.all(loaderErrors)).toEqual([]);
expect(pageErrors).toEqual([]);
// In debug mode the loader also injects this style as a red page banner.
await expect(page.locator("style.o_module_error_banner")).toHaveCount(0);
console.log("✅ Test assets loaded with no module loader errors");
} finally {
page.off("console", onConsole);
page.off("pageerror", onPageError);
}

await logout(page);
console.log("✅ Admin logged out");
});
});
8 changes: 8 additions & 0 deletions spp_cel_widget/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ Dependencies
Changelog
=========

19.0.2.0.1
~~~~~~~~~~

- fix: import the tours' stepUtils from the Odoo 19 path
@web_tour/tour_utils and drop the pre-18 ``test`` tour key that Odoo
19 rejects, so web.assets_tests loads without module loader errors and
no longer fails every backend tour (#551)

19.0.2.0.0
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_cel_widget/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "OpenSPP CEL Expression Widget",
"version": "19.0.2.0.0",
"version": "19.0.2.0.1",
"category": "OpenSPP",
"summary": "Reusable CEL expression editor with syntax highlighting and autocomplete",
"author": "OpenSPP.org, OpenSPP",
Expand Down
6 changes: 6 additions & 0 deletions spp_cel_widget/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
### 19.0.2.0.1

- fix: import the tours' stepUtils from the Odoo 19 path @web_tour/tour_utils and drop the pre-18
`test` tour key that Odoo 19 rejects, so web.assets_tests loads without module loader errors and
no longer fails every backend tour (#551)

### 19.0.2.0.0

- Initial migration to OpenSPP2
9 changes: 9 additions & 0 deletions spp_cel_widget/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,15 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.2.0.1</h1>
<ul class="simple">
<li>fix: import the tours’ stepUtils from the Odoo 19 path
&#64;web_tour/tour_utils and drop the pre-18 <tt class="docutils literal">test</tt> tour key that Odoo
19 rejects, so web.assets_tests loads without module loader errors and
no longer fails every backend tour (#551)</li>
</ul>
</div>
<div class="section" id="section-2">
<h1>19.0.2.0.0</h1>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
Expand Down
12 changes: 1 addition & 11 deletions spp_cel_widget/static/tests/tours/cel_widget_tour.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import {registry} from "@web/core/registry";
import {stepUtils} from "@web_tour/tour_service/tour_utils";
import {stepUtils} from "@web_tour/tour_utils";

/**
* Tour 1: Basic Widget Rendering and Initialization
Expand All @@ -17,7 +17,6 @@ import {stepUtils} from "@web_tour/tour_service/tour_utils";
* Verifies all UI components are present.
*/
registry.category("web_tour.tours").add("cel_widget_basic_rendering", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down Expand Up @@ -78,7 +77,6 @@ registry.category("web_tour.tours").add("cel_widget_basic_rendering", {
* Tests the autocomplete functionality by typing 'me.' and selecting a field.
*/
registry.category("web_tour.tours").add("cel_widget_autocomplete", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down Expand Up @@ -142,7 +140,6 @@ registry.category("web_tour.tours").add("cel_widget_autocomplete", {
* Tests that entering a valid expression shows success validation feedback.
*/
registry.category("web_tour.tours").add("cel_widget_validation_success", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down Expand Up @@ -209,7 +206,6 @@ registry.category("web_tour.tours").add("cel_widget_validation_success", {
* Tests that entering an invalid expression shows error feedback.
*/
registry.category("web_tour.tours").add("cel_widget_validation_error", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down Expand Up @@ -272,7 +268,6 @@ registry.category("web_tour.tours").add("cel_widget_validation_error", {
* Tests opening the symbol browser, navigating tabs, searching, and inserting symbols.
*/
registry.category("web_tour.tours").add("cel_widget_symbol_browser", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down Expand Up @@ -395,7 +390,6 @@ registry.category("web_tour.tours").add("cel_widget_symbol_browser", {
* Tests the search/filter functionality within the symbol browser.
*/
registry.category("web_tour.tours").add("cel_widget_symbol_search", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down Expand Up @@ -478,7 +472,6 @@ registry.category("web_tour.tours").add("cel_widget_symbol_search", {
* Tests that clicking the autocomplete button (Ctrl+Space alternative) works.
*/
registry.category("web_tour.tours").add("cel_widget_manual_autocomplete", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down Expand Up @@ -538,7 +531,6 @@ registry.category("web_tour.tours").add("cel_widget_manual_autocomplete", {
* Tests that the widget properly disables editing in readonly mode.
*/
registry.category("web_tour.tours").add("cel_widget_readonly", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down Expand Up @@ -582,7 +574,6 @@ registry.category("web_tour.tours").add("cel_widget_readonly", {
* Tests that empty expressions don't show validation errors initially.
*/
registry.category("web_tour.tours").add("cel_widget_empty_validation", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down Expand Up @@ -656,7 +647,6 @@ registry.category("web_tour.tours").add("cel_widget_empty_validation", {
* Tests entering a complex expression with operators, functions, and field access.
*/
registry.category("web_tour.tours").add("cel_widget_complex_expression", {
test: true,
url: "/web",
steps: () => [
stepUtils.showAppsMenuItem(),
Expand Down
1 change: 1 addition & 0 deletions spp_cel_widget/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from . import test_symbol_provider
from . import test_controller
from . import test_assets
165 changes: 165 additions & 0 deletions spp_cel_widget/tests/test_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Tests that the module's JavaScript imports resolve in the bundles that load them.

Odoo's module loader logs a console error for every import that no loaded file
defines, and the browser test runner fails any tour on a console error. The
test image has no Chrome, so tours never run in CI; these tests check import
resolution statically, using Odoo's own transpiler to read each file's
dependencies.

Limitation: a module defined only in a lazy-loaded bundle is not in the
defined set, so an import of one would be reported as unresolved.
"""

import ast
import re

from odoo.tests import tagged
from odoo.tests.common import BaseCase, TransactionCase
from odoo.tools.js_transpiler import (
ODOO_MODULE_RE,
is_odoo_module,
transpile_javascript,
url_to_module_path,
)
from odoo.tools.misc import file_open

from odoo.addons.base.models.assetsbundle import WebAsset

# web.webclient_bootstrap loads web.assets_web, then web.assets_tests in test mode.
BACKEND_TEST_BUNDLES = ("web.assets_web", "web.assets_tests")

TOUR_FILE = "/spp_cel_widget/static/tests/tours/cel_widget_tour.js"

ODOO_DEFINE_RE = re.compile(r"""odoo\.define\(\s*(['"])(?P<name>.+?)\1,\s*(?P<deps>\[.*?\])""", re.DOTALL)


def transpiled_dependencies(transpiled_content):
"""Return the module names a transpiled JS module imports, read from its odoo.define call."""
match = ODOO_DEFINE_RE.search(transpiled_content)
return ast.literal_eval(match["deps"])


def module_dependencies(url, content):
"""Return the module names a JS file imports, as Odoo's transpiler resolves them."""
return transpiled_dependencies(transpile_javascript(url, content))


def defined_module_names(url, content):
"""Return the module names a JS file defines when it is loaded."""
names = set()
if is_odoo_module(url, content):
names.add(url_to_module_path(url))
header = ODOO_MODULE_RE.match(content)
if header and header["alias"]:
names.add(header["alias"])
else:
# Files that call odoo.define by hand, e.g. the "@odoo/owl" wrapper in web/static/lib.
# A transpiled module never does, so its comments and strings are not scanned.
names.update(match["name"] for match in ODOO_DEFINE_RE.finditer(content))
return names


class TestAssetImportHelpers(BaseCase):
"""Unit tests for the helpers, so the bundle test cannot pass by matching nothing."""

def test_module_dependencies_resolves_named_bare_and_relative_imports(self):
content = (
'import {registry} from "@web/core/registry";\n'
'import "@web_tour/tour_utils";\n'
'import {helper} from "./sibling";\n'
)
self.assertCountEqual(
module_dependencies("/spp_cel_widget/static/src/js/example.js", content),
["@web/core/registry", "@web_tour/tour_utils", "@spp_cel_widget/js/sibling"],
)

def test_module_dependencies_reads_the_tour_file_web_tour_import(self):
with file_open(TOUR_FILE.lstrip("/")) as tour_file:
dependencies = module_dependencies(TOUR_FILE, tour_file.read())
# Odoo 19's path; the pre-19 "@web_tour/tour_service/tour_utils" must fail here.
self.assertIn(
"@web_tour/tour_utils",
dependencies,
f"Expected the Odoo 19 @web_tour/tour_utils import in {TOUR_FILE}, got {dependencies}",
)

def test_defined_module_names_uses_the_path_and_alias(self):
content = "/** @odoo-module alias=spp_cel_widget.Example **/\nexport const x = 1;\n"
self.assertEqual(
defined_module_names("/spp_cel_widget/static/src/js/example.js", content),
{"@spp_cel_widget/js/example", "spp_cel_widget.Example"},
)

def test_defined_module_names_reads_a_hand_written_define(self):
content = 'odoo.define("@odoo/owl", [], function () {\n return owl;\n});\n'
self.assertEqual(
defined_module_names("/web/static/lib/owl/odoo_module.js", content),
{"@odoo/owl"},
)

def test_defined_module_names_ignores_define_text_in_a_transpiled_module(self):
# A transpiled module never contains a hand-written define, so a match in its
# comments or strings is not a module; counting it could hide a missing import.
content = (
"/** @odoo-module **/\n"
'/** Example: odoo.define("@example/not_a_module", [], function () {}); */\n'
"export const x = 1;\n"
)
self.assertEqual(
defined_module_names("/spp_cel_widget/static/src/js/example.js", content),
{"@spp_cel_widget/js/example"},
)

def test_defined_module_names_reads_a_multi_line_hand_written_define(self):
# Mirrors spreadsheet/static/src/o_spreadsheet/odoo_module.js.
content = (
"// @odoo-module ignore\n\n"
'odoo.define(\n "@odoo/o-spreadsheet",\n'
' ["@web/core/l10n/translation", "@spreadsheet/o_spreadsheet/o_spreadsheet"],\n'
" function (require) {\n return {};\n }\n);\n"
)
self.assertEqual(
defined_module_names("/spreadsheet/static/src/o_spreadsheet/odoo_module.js", content),
{"@odoo/o-spreadsheet"},
)


@tagged("post_install", "-at_install")
class TestCelWidgetAssetImports(TransactionCase):
"""Integration test: every import in this module's JS resolves on the backend test page."""

def _backend_test_page_scripts(self):
"""Return ``{url: JavascriptAsset}`` for the scripts the backend test page loads.

Odoo's own bundle objects decide what is transpiled and serve the transpiled
content, and they also read ``ir.asset`` entries stored as attachments.
"""
scripts = {}
for bundle in BACKEND_TEST_BUNDLES:
for asset in self.env["ir.qweb"]._get_asset_bundle(bundle, css=False).javascripts:
scripts.setdefault(asset.url, asset)
return scripts

def test_imports_resolve_on_the_backend_test_page(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: nothing that runs on PRs guards the second bug. If someone adds test: true (or any key outside {name, steps, url, wait_for}) to a tour, PR CI stays green and only test 24 catches it, after merge. A cheap static check on the keys passed to registry.category("web_tour.tours").add(...) in own_modules would catch it at PR time. I know you weighed this and chose to leave it to test 24, so take it or leave it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've left this to e2e test 24, as before. The check needs the top-level keys of the object literal passed to
registry.category("web_tour.tours").add(...). steps is an array of object literals whose own keys (trigger, run,
content…) sit at deeper nesting, so a regex can't tell the two levels apart reliably. Doing it properly means
brace-matching that is aware of strings, comments and template literals, or a JS parser in the Python test. That's more
machinery than the bug class warrants, and it would still miss keys built at runtime. Test 24 checks what actually fails,
Odoo's TourSchema validation at load time.

The PR-time gap you point out is real: test 24 runs on merge, not on PR. I'd rather close it by running test 24 in PR CI
(or making #553's tours go away) than with a partial static check. Happy to revisit if you feel strongly.

scripts = self._backend_test_page_scripts()
# The source as written: the @odoo-module header (with its alias) and any
# hand-written odoo.define live there, not in the transpiled output.
# JavascriptAsset reads it through this same base-class property.
defined = set().union(
*(defined_module_names(url, WebAsset.content.fget(asset)) for url, asset in scripts.items())
)
own_modules = {
url: asset for url, asset in scripts.items() if url.startswith("/spp_cel_widget/") and asset.is_transpiled
}
self.assertIn(TOUR_FILE, own_modules)

unresolved = {}
for url, asset in own_modules.items():
missing = sorted(set(transpiled_dependencies(asset.content)) - defined)
if missing:
unresolved[url] = missing
self.assertFalse(
unresolved,
f"Imports that no file on the backend test page defines (the module loader "
f"logs a console error for each, failing every tour): {unresolved}",
)
Loading