From be6ee88646f9a483603b0a6715dcd02c101d2a69 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:17:22 +0100 Subject: [PATCH 1/5] perf: cache empty search links --- frappe/desk/search.py | 6 ++++++ frappe/utils/caching.py | 11 ++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 4c9a92e698..4b913dab66 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -15,6 +15,7 @@ from frappe.database.schema import SPECIAL_CHAR_PATTERN from frappe.model.db_query import get_order_by from frappe.permissions import has_permission from frappe.utils import cint, cstr, escape_html, unique +from frappe.utils.caching import redis_cache from frappe.utils.data import make_filter_tuple @@ -32,8 +33,13 @@ class LinkSearchResults(TypedDict): label: NotRequired[str] +def should_cache(doctype, txt, *args, **kwargs): + return not txt + + # this is called by the Link Field @frappe.whitelist() +@redis_cache(ttl=60 * 5, user=True, condition=should_cache) def search_link( doctype: str, txt: str, diff --git a/frappe/utils/caching.py b/frappe/utils/caching.py index f2f1f9aecd..62be7f83b6 100644 --- a/frappe/utils/caching.py +++ b/frappe/utils/caching.py @@ -167,13 +167,19 @@ def site_cache(ttl: int | None = None, maxsize: int | None = None) -> Callable: return time_cache_wrapper -def redis_cache(ttl: int | None = 3600, user: str | bool | None = None, shared: bool = False) -> Callable: +def redis_cache( + ttl: int | None = 3600, + user: str | bool | None = None, + shared: bool = False, + condition: Callable | None = None, +) -> Callable: """Decorator to cache method calls and its return values in Redis args: ttl: time to expiry in seconds, defaults to 1 hour user: `true` should cache be specific to session user. shared: `true` should cache be shared across sites + condition: A callable that returns `True` if the cache should be used, `False` otherwise. """ def wrapper(func: Callable | None = None) -> Callable: @@ -187,6 +193,9 @@ def redis_cache(ttl: int | None = 3600, user: str | bool | None = None, shared: @wraps(func) def redis_cache_wrapper(*args, **kwargs): + if condition and not condition(*args, **kwargs): + return func(*args, **kwargs) + func_call_key = f"{func_key}::{hash(__generate_request_cache_key(args, kwargs))}" cached_val = frappe.cache.get_value(func_call_key, user=user, shared=shared) if cached_val is not None: From 8e701a61f71343ac3524feb63b028fb9a88e7bb0 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:37:10 +0100 Subject: [PATCH 2/5] fix: check if filters are cacheable --- frappe/desk/search.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 4b913dab66..a1fd529e83 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -34,7 +34,9 @@ class LinkSearchResults(TypedDict): def should_cache(doctype, txt, *args, **kwargs): - return not txt + """Return True if there is no search text and the filters are cacheable.""" + filters = kwargs.get("filters") + return not txt and not isinstance(filters, dict | list) # this is called by the Link Field From b0800cacf6c0b1bc4f967a3d5b60a559a84f9de5 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:59:42 +0100 Subject: [PATCH 3/5] test: condition for redis_cache --- frappe/tests/test_caching.py | 141 +++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/frappe/tests/test_caching.py b/frappe/tests/test_caching.py index 4111ca8dfb..18908fa3d2 100644 --- a/frappe/tests/test_caching.py +++ b/frappe/tests/test_caching.py @@ -226,6 +226,147 @@ class TestRedisCache(FrappeAPITestCase): self.assertEqual(calculate_area(1), PI) self.assertEqual(function_call_count, 2) + def test_condition_returns_true_uses_cache(self): + """Cache should be used when condition returns True.""" + function_call_count = 0 + + def always_cache(*args, **kwargs): + return True + + @redis_cache(ttl=CACHE_TTL, condition=always_cache) + def calculate_area(radius: float) -> float: + nonlocal function_call_count + function_call_count += 1 + return 3.14 * radius**2 + + calculate_area.clear_cache() + self.assertEqual(calculate_area(10), 314) + self.assertEqual(function_call_count, 1) + + # Second call should use cache + self.assertEqual(calculate_area(10), 314) + self.assertEqual(function_call_count, 1) + + calculate_area.clear_cache() + + def test_condition_returns_false_bypasses_cache(self): + """Cache should be bypassed when condition returns False.""" + function_call_count = 0 + + def never_cache(*args, **kwargs): + return False + + @redis_cache(ttl=CACHE_TTL, condition=never_cache) + def calculate_area(radius: float) -> float: + nonlocal function_call_count + function_call_count += 1 + return 3.14 * radius**2 + + calculate_area.clear_cache() + self.assertEqual(calculate_area(10), 314) + self.assertEqual(function_call_count, 1) + + # Second call should NOT use cache, function should be called again + self.assertEqual(calculate_area(10), 314) + self.assertEqual(function_call_count, 2) + + # Third call should also bypass cache + self.assertEqual(calculate_area(10), 314) + self.assertEqual(function_call_count, 3) + + calculate_area.clear_cache() + + def test_condition_receives_correct_arguments(self): + """Condition function should receive the same args and kwargs as the decorated function.""" + received_args = [] + received_kwargs = [] + + def capture_args(*args, **kwargs): + received_args.append(args) + received_kwargs.append(kwargs) + return True + + @redis_cache(ttl=CACHE_TTL, condition=capture_args) + def calculate_area(radius: float, precision: int = 2) -> float: + return round(3.14159 * radius**2, precision) + + calculate_area.clear_cache() + + # Test with positional args + calculate_area(10) + self.assertEqual(received_args[-1], (10,)) + self.assertEqual(received_kwargs[-1], {}) + + # Test with kwargs + calculate_area(radius=5, precision=3) + self.assertEqual(received_args[-1], ()) + self.assertEqual(received_kwargs[-1], {"radius": 5, "precision": 3}) + + # Test with mixed args and kwargs + calculate_area(7, precision=4) + self.assertEqual(received_args[-1], (7,)) + self.assertEqual(received_kwargs[-1], {"precision": 4}) + + calculate_area.clear_cache() + + def test_condition_none_uses_cache_by_default(self): + """When condition is not provided (default), cache should always be used.""" + function_call_count = 0 + + @redis_cache(ttl=CACHE_TTL) + def calculate_area(radius: float) -> float: + nonlocal function_call_count + function_call_count += 1 + return 3.14 * radius**2 + + calculate_area.clear_cache() + self.assertEqual(calculate_area(10), 314) + self.assertEqual(function_call_count, 1) + + # Second call should use cache + self.assertEqual(calculate_area(10), 314) + self.assertEqual(function_call_count, 1) + + # Different args should miss cache + self.assertEqual(calculate_area(5), 78.5) + self.assertEqual(function_call_count, 2) + + # Same args should hit cache again + self.assertEqual(calculate_area(5), 78.5) + self.assertEqual(function_call_count, 2) + + calculate_area.clear_cache() + + def test_condition_dynamic_behavior(self): + """Condition can dynamically decide whether to cache based on arguments.""" + function_call_count = 0 + + def cache_only_for_large_radius(radius, **kwargs): + # Only cache results for radius >= 100 + return radius >= 100 + + @redis_cache(ttl=CACHE_TTL, condition=cache_only_for_large_radius) + def calculate_area(radius: float) -> float: + nonlocal function_call_count + function_call_count += 1 + return 3.14 * radius**2 + + calculate_area.clear_cache() + + # Small radius - should not cache + self.assertEqual(calculate_area(10), 314) + self.assertEqual(function_call_count, 1) + self.assertEqual(calculate_area(10), 314) + self.assertEqual(function_call_count, 2) # Called again, not cached + + # Large radius - should cache + self.assertEqual(calculate_area(100), 31400) + self.assertEqual(function_call_count, 3) + self.assertEqual(calculate_area(100), 31400) + self.assertEqual(function_call_count, 3) # Cached, not called again + + calculate_area.clear_cache() + class TestDocumentCache(FrappeAPITestCase): TEST_DOCTYPE = "User" From cce23d6699af607badeb7296dca2c8da2d2fe226 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 13 Dec 2025 17:08:53 +0100 Subject: [PATCH 4/5] refactor: move to http_cache --- frappe/desk/search.py | 10 +- frappe/public/js/frappe/form/controls/link.js | 32 +++- frappe/tests/test_caching.py | 141 ------------------ frappe/utils/caching.py | 11 +- 4 files changed, 34 insertions(+), 160 deletions(-) diff --git a/frappe/desk/search.py b/frappe/desk/search.py index a1fd529e83..9bd9fc1162 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -15,7 +15,7 @@ from frappe.database.schema import SPECIAL_CHAR_PATTERN from frappe.model.db_query import get_order_by from frappe.permissions import has_permission from frappe.utils import cint, cstr, escape_html, unique -from frappe.utils.caching import redis_cache +from frappe.utils.caching import http_cache from frappe.utils.data import make_filter_tuple @@ -33,15 +33,9 @@ class LinkSearchResults(TypedDict): label: NotRequired[str] -def should_cache(doctype, txt, *args, **kwargs): - """Return True if there is no search text and the filters are cacheable.""" - filters = kwargs.get("filters") - return not txt and not isinstance(filters, dict | list) - - # this is called by the Link Field @frappe.whitelist() -@redis_cache(ttl=60 * 5, user=True, condition=should_cache) +@http_cache(max_age=60 * 5, stale_while_revalidate=60 * 5) def search_link( doctype: str, txt: str, diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index ce3f113ff9..c0a626fedb 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -340,6 +340,34 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat }); } + /** + * Determine if we should use GET (enables HTTP caching) or POST. + * Use GET for empty searches with filters that fit in URL. + * Use POST for searches with text or large filters. + */ + should_use_post_for_search(txt, filters, max_get_size = 2000) { + // Always use POST if there's search text + if (txt) return true; + + // If no filters, use GET + if (!filters) return false; + + // Check size of filters when stringified + let filters_str = filters; + if (typeof filters !== "string") { + try { + filters_str = JSON.stringify(filters); + } catch (e) { + // If stringification fails, use POST + return true; + } + } + + // URL-encoded params add ~30% overhead on average + const estimated_size = filters_str.length * 1.3; + return estimated_size > max_get_size; + } + on_input(e) { var doctype = this.get_options(); if (!doctype) return; @@ -364,10 +392,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat this.set_custom_query(args); + const use_get = !this.should_use_post_for_search(term, args.filters); frappe.call({ - type: "POST", + type: use_get ? "GET" : "POST", method: "frappe.desk.search.search_link", no_spinner: true, + cache: use_get, args: args, callback: (r) => { if (!window.Cypress && !this.$input.is(":focus")) { diff --git a/frappe/tests/test_caching.py b/frappe/tests/test_caching.py index 18908fa3d2..4111ca8dfb 100644 --- a/frappe/tests/test_caching.py +++ b/frappe/tests/test_caching.py @@ -226,147 +226,6 @@ class TestRedisCache(FrappeAPITestCase): self.assertEqual(calculate_area(1), PI) self.assertEqual(function_call_count, 2) - def test_condition_returns_true_uses_cache(self): - """Cache should be used when condition returns True.""" - function_call_count = 0 - - def always_cache(*args, **kwargs): - return True - - @redis_cache(ttl=CACHE_TTL, condition=always_cache) - def calculate_area(radius: float) -> float: - nonlocal function_call_count - function_call_count += 1 - return 3.14 * radius**2 - - calculate_area.clear_cache() - self.assertEqual(calculate_area(10), 314) - self.assertEqual(function_call_count, 1) - - # Second call should use cache - self.assertEqual(calculate_area(10), 314) - self.assertEqual(function_call_count, 1) - - calculate_area.clear_cache() - - def test_condition_returns_false_bypasses_cache(self): - """Cache should be bypassed when condition returns False.""" - function_call_count = 0 - - def never_cache(*args, **kwargs): - return False - - @redis_cache(ttl=CACHE_TTL, condition=never_cache) - def calculate_area(radius: float) -> float: - nonlocal function_call_count - function_call_count += 1 - return 3.14 * radius**2 - - calculate_area.clear_cache() - self.assertEqual(calculate_area(10), 314) - self.assertEqual(function_call_count, 1) - - # Second call should NOT use cache, function should be called again - self.assertEqual(calculate_area(10), 314) - self.assertEqual(function_call_count, 2) - - # Third call should also bypass cache - self.assertEqual(calculate_area(10), 314) - self.assertEqual(function_call_count, 3) - - calculate_area.clear_cache() - - def test_condition_receives_correct_arguments(self): - """Condition function should receive the same args and kwargs as the decorated function.""" - received_args = [] - received_kwargs = [] - - def capture_args(*args, **kwargs): - received_args.append(args) - received_kwargs.append(kwargs) - return True - - @redis_cache(ttl=CACHE_TTL, condition=capture_args) - def calculate_area(radius: float, precision: int = 2) -> float: - return round(3.14159 * radius**2, precision) - - calculate_area.clear_cache() - - # Test with positional args - calculate_area(10) - self.assertEqual(received_args[-1], (10,)) - self.assertEqual(received_kwargs[-1], {}) - - # Test with kwargs - calculate_area(radius=5, precision=3) - self.assertEqual(received_args[-1], ()) - self.assertEqual(received_kwargs[-1], {"radius": 5, "precision": 3}) - - # Test with mixed args and kwargs - calculate_area(7, precision=4) - self.assertEqual(received_args[-1], (7,)) - self.assertEqual(received_kwargs[-1], {"precision": 4}) - - calculate_area.clear_cache() - - def test_condition_none_uses_cache_by_default(self): - """When condition is not provided (default), cache should always be used.""" - function_call_count = 0 - - @redis_cache(ttl=CACHE_TTL) - def calculate_area(radius: float) -> float: - nonlocal function_call_count - function_call_count += 1 - return 3.14 * radius**2 - - calculate_area.clear_cache() - self.assertEqual(calculate_area(10), 314) - self.assertEqual(function_call_count, 1) - - # Second call should use cache - self.assertEqual(calculate_area(10), 314) - self.assertEqual(function_call_count, 1) - - # Different args should miss cache - self.assertEqual(calculate_area(5), 78.5) - self.assertEqual(function_call_count, 2) - - # Same args should hit cache again - self.assertEqual(calculate_area(5), 78.5) - self.assertEqual(function_call_count, 2) - - calculate_area.clear_cache() - - def test_condition_dynamic_behavior(self): - """Condition can dynamically decide whether to cache based on arguments.""" - function_call_count = 0 - - def cache_only_for_large_radius(radius, **kwargs): - # Only cache results for radius >= 100 - return radius >= 100 - - @redis_cache(ttl=CACHE_TTL, condition=cache_only_for_large_radius) - def calculate_area(radius: float) -> float: - nonlocal function_call_count - function_call_count += 1 - return 3.14 * radius**2 - - calculate_area.clear_cache() - - # Small radius - should not cache - self.assertEqual(calculate_area(10), 314) - self.assertEqual(function_call_count, 1) - self.assertEqual(calculate_area(10), 314) - self.assertEqual(function_call_count, 2) # Called again, not cached - - # Large radius - should cache - self.assertEqual(calculate_area(100), 31400) - self.assertEqual(function_call_count, 3) - self.assertEqual(calculate_area(100), 31400) - self.assertEqual(function_call_count, 3) # Cached, not called again - - calculate_area.clear_cache() - class TestDocumentCache(FrappeAPITestCase): TEST_DOCTYPE = "User" diff --git a/frappe/utils/caching.py b/frappe/utils/caching.py index 62be7f83b6..f2f1f9aecd 100644 --- a/frappe/utils/caching.py +++ b/frappe/utils/caching.py @@ -167,19 +167,13 @@ def site_cache(ttl: int | None = None, maxsize: int | None = None) -> Callable: return time_cache_wrapper -def redis_cache( - ttl: int | None = 3600, - user: str | bool | None = None, - shared: bool = False, - condition: Callable | None = None, -) -> Callable: +def redis_cache(ttl: int | None = 3600, user: str | bool | None = None, shared: bool = False) -> Callable: """Decorator to cache method calls and its return values in Redis args: ttl: time to expiry in seconds, defaults to 1 hour user: `true` should cache be specific to session user. shared: `true` should cache be shared across sites - condition: A callable that returns `True` if the cache should be used, `False` otherwise. """ def wrapper(func: Callable | None = None) -> Callable: @@ -193,9 +187,6 @@ def redis_cache( @wraps(func) def redis_cache_wrapper(*args, **kwargs): - if condition and not condition(*args, **kwargs): - return func(*args, **kwargs) - func_call_key = f"{func_key}::{hash(__generate_request_cache_key(args, kwargs))}" cached_val = frappe.cache.get_value(func_call_key, user=user, shared=shared) if cached_val is not None: From 68b18d8a938aa8625a06c272dd65159456276e53 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Sat, 13 Dec 2025 17:43:07 +0100 Subject: [PATCH 5/5] test: don't wait for cached request --- cypress/integration/control_link.js | 56 ++++++++++++++--------------- cypress/integration/form_builder.js | 13 ++++--- cypress/support/commands.js | 5 ++- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 2e2cf92deb..4c0587b009 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -58,13 +58,12 @@ context("Control Link", () => { true ); - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); - cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); - cy.wait("@search_link"); - cy.wait(500); + // Wait for dropdown to appear (request might be cached) + cy.get("@input").parent().findByRole("listbox").should("be.visible"); + cy.wait(200); cy.get("@input").type("todo for link", { delay: 100 }); - cy.wait("@search_link"); + // Wait for dropdown to update with search results cy.wait(500); cy.get("@input").parent().findByRole("listbox").should("be.visible"); cy.get("@input").type("{enter}"); @@ -81,10 +80,10 @@ context("Control Link", () => { get_dialog_with_link().as("dialog"); cy.intercept("/api/method/frappe.client.validate_link*").as("validate_link"); - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); - cy.wait("@search_link"); - cy.wait(500); + // Wait for dropdown to appear (request might be cached) + cy.get("@input").parent().findByRole("listbox").should("be.visible"); + cy.wait(200); cy.get("@input").type("invalid value", { delay: 100 }).blur(); cy.wait("@validate_link"); cy.get("@input").should("have.value", ""); @@ -94,11 +93,11 @@ context("Control Link", () => { get_dialog_with_link().as("dialog"); cy.intercept("/api/method/frappe.client.validate_link*").as("validate_link"); - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); - cy.wait("@search_link"); - cy.wait(500); + // Wait for dropdown to appear (request might be cached) + cy.get("@input").parent().findByRole("listbox").should("be.visible"); + cy.wait(200); cy.get("@input").type(" ", { delay: 100 }).blur(); cy.wait("@validate_link"); cy.get("@input").should("have.value", ""); @@ -112,12 +111,11 @@ context("Control Link", () => { it("should show open link button", () => { get_dialog_with_link().as("dialog"); - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); - cy.get("@todos").then((todos) => { cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); - cy.wait("@search_link"); - cy.wait(500); + // Wait for dropdown to appear (request might be cached) + cy.get("@input").parent().findByRole("listbox").should("be.visible"); + cy.wait(200); cy.get("@input").type(todos[0], { delay: 100 }).blur(); // not waiting for validate_link because it will not get called cy.get("@input").trigger("mouseover"); @@ -156,13 +154,12 @@ context("Control Link", () => { } }); - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); - cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); - cy.wait("@search_link"); - cy.wait(500); + // Wait for dropdown to appear (request might be cached) + cy.get("@input").parent().findByRole("listbox").should("be.visible"); + cy.wait(200); cy.get("@input").type("todo for link", { delay: 100 }); - cy.wait("@search_link"); + // Wait for dropdown to update with search results cy.wait(500); cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible"); cy.get("@input").type("{enter}"); @@ -182,7 +179,6 @@ context("Control Link", () => { it("should update dependant fields (via fetch_from)", () => { cy.get("@todos").then((todos) => { cy.visit(`/desk/todo/${todos[0]}`); - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); cy.intercept("/api/method/frappe.client.validate_link*").as("validate_link"); cy.fill_field("assigned_by", cy.config("testUser"), "Link"); @@ -211,7 +207,9 @@ context("Control Link", () => { // set valid value again cy.get("@input").clear().focus(); - cy.wait("@search_link"); + // Wait for dropdown to appear (request might be cached) + cy.get("@input").parent().findByRole("listbox").should("be.visible"); + cy.wait(200); cy.get("@input").type(cy.config("testUser"), { delay: 100 }).blur(); cy.wait("@validate_link"); @@ -280,12 +278,13 @@ context("Control Link", () => { cy.wait(500); get_dialog_with_gender_link().as("dialog"); - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); - cy.wait("@search_link"); + // Wait for dropdown to appear (request might be cached) + cy.get("@input").parent().findByRole("listbox").should("be.visible"); + cy.wait(200); cy.get("@input").type("Sonstiges", { delay: 100 }); - cy.wait("@search_link"); + // Wait for dropdown to update with search results cy.wait(500); cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible"); cy.get(".frappe-control[data-fieldname=link] input").type("{enter}"); @@ -312,12 +311,13 @@ context("Control Link", () => { cy.wait(1000); get_dialog_with_gender_link().as("dialog"); - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); cy.get(".frappe-control[data-fieldname=link] input").focus().as("input"); - cy.wait("@search_link"); + // Wait for dropdown to appear (request might be cached) + cy.get("@input").parent().findByRole("listbox").should("be.visible"); + cy.wait(200); cy.get("@input").type("Non-Conforming", { delay: 100 }); - cy.wait("@search_link"); + // Wait for dropdown to update with search results cy.wait(500); cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible"); cy.get(".frappe-control[data-fieldname=link] input").type("{enter}"); diff --git a/cypress/integration/form_builder.js b/cypress/integration/form_builder.js index 8677710bc4..37780eefce 100644 --- a/cypress/integration/form_builder.js +++ b/cypress/integration/form_builder.js @@ -50,14 +50,14 @@ context("Form Builder", () => { cy.get(".modal-body .filter-action-buttons .add-filter").click(); cy.wait(100); - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); cy.get(".modal-body .filter-box .list_filter .filter-field .link-field input") .focus() .as("input"); - cy.wait("@search_link"); - cy.wait(500); + // Wait for dropdown to appear (request might be cached) + cy.get("@input").parent().findByRole("listbox").should("be.visible"); + cy.wait(200); cy.get("@input").type("Male", { delay: 100 }); - cy.wait("@search_link"); + // Wait for dropdown to update with search results cy.wait(500); cy.get("@input").type("{enter}", { delay: 100 }); cy.get("@input").blur(); @@ -97,8 +97,6 @@ context("Form Builder", () => { }); it("Add Table field and check if columns are rendered", () => { - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); - cy.visit(`/app/doctype/${doctype_name}`); cy.findByRole("tab", { name: "Form" }).click(); @@ -127,7 +125,8 @@ context("Form Builder", () => { .click() .as("input"); cy.get("@input").clear({ force: true }).type("Web Form Field", { delay: 200 }); - cy.wait("@search_link"); + // Wait for dropdown to appear and selection to complete + cy.wait(500); cy.get(last_field).click({ force: true }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index f774dc9872..b7fa6d1003 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -172,13 +172,12 @@ Cypress.Commands.add("fill_field", (fieldname, value, fieldtype = "Data") => { } if (["Link", "Dynamic Link"].includes(fieldtype)) { - cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link"); cy.get("@input").clear().focus(); - cy.wait("@search_link"); + // Wait for dropdown to appear (request might be cached, so don't wait for network) cy.get("@input").parent().findByRole("listbox").as("dropdown"); cy.get("@dropdown").should("be.visible"); cy.get("@input").type(value, { delay: 100 }); - cy.wait("@search_link"); + // Wait for dropdown to update with search results cy.get("@dropdown") .should("be.visible") .find("div[role='option']")