From e12d9cd50b1f78b738f2edc20bdc5f3cdb1c4d29 Mon Sep 17 00:00:00 2001
From: Tomas Peterka <tomas.peterka@nexedi.com>
Date: Wed, 3 Jan 2018 18:31:20 +0100
Subject: [PATCH] [hal_json_style] Improve search-value resolution (bug
 20171215-133705C)

-  introduce External Function for Acquisition resolution to get over permissions
-  improve recursive TALES expression support
-  closely follow value resolution from ListBox
-  remove external method for default value resolution
---
 .../extension.erp5.HalStyle.py                |  11 +-
 ...ld_getDefaultValue.xml => Base_aqBase.xml} |   4 +-
 .../erp5_hal_json_style/Base_aqSelf.xml       |  28 +++++
 .../ERP5Document_getHateoas.py                | 103 ++++++++----------
 .../erp5_ui_test/FooModule_listAsContext.py   |  16 +++
 .../erp5_ui_test/FooModule_listAsContext.xml  |  62 +++++++++++
 .../testListboxValueAsContext.xml             |  58 ++++++++++
 .../testListboxValueAsContext.zpt             |  56 ++++++++++
 8 files changed, 278 insertions(+), 60 deletions(-)
 rename bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/{Field_getDefaultValue.xml => Base_aqBase.xml} (83%)
 create mode 100644 bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Base_aqSelf.xml
 create mode 100644 bt5/erp5_ui_test/SkinTemplateItem/portal_skins/erp5_ui_test/FooModule_listAsContext.py
 create mode 100644 bt5/erp5_ui_test/SkinTemplateItem/portal_skins/erp5_ui_test/FooModule_listAsContext.xml
 create mode 100644 bt5/erp5_web_renderjs_ui_test/PathTemplateItem/portal_tests/renderjs_ui_listbox_zuite/testListboxValueAsContext.xml
 create mode 100644 bt5/erp5_web_renderjs_ui_test/PathTemplateItem/portal_tests/renderjs_ui_listbox_zuite/testListboxValueAsContext.zpt

diff --git a/bt5/erp5_hal_json_style/ExtensionTemplateItem/portal_components/extension.erp5.HalStyle.py b/bt5/erp5_hal_json_style/ExtensionTemplateItem/portal_components/extension.erp5.HalStyle.py
index 333016a197..55fcceb03f 100644
--- a/bt5/erp5_hal_json_style/ExtensionTemplateItem/portal_components/extension.erp5.HalStyle.py
+++ b/bt5/erp5_hal_json_style/ExtensionTemplateItem/portal_components/extension.erp5.HalStyle.py
@@ -1,10 +1,15 @@
+from Acquisition import aq_self, aq_base
+
+def Base_aqSelf(self):
+  return aq_self(self)
+
+def Base_aqBase(self):
+  return aq_base(self)
+
 def Field_getSubFieldKeyDict(self, field, field_id, key=None):
   """XXX"""
   return field.generate_subfield_key(field_id, key=key)
 
-def Field_getDefaultValue(self, field, key, value, REQUEST):
-  return field._get_default(key, value, REQUEST)
-
 def WorkflowTool_listActionParameterList(self):
   action_list = self.listActions()
   info = self._getOAI(None)
diff --git a/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Field_getDefaultValue.xml b/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Base_aqBase.xml
similarity index 83%
rename from bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Field_getDefaultValue.xml
rename to bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Base_aqBase.xml
index 9268b3b28f..eb2ec46e42 100644
--- a/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Field_getDefaultValue.xml
+++ b/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Base_aqBase.xml
@@ -8,7 +8,7 @@
       <dictionary>
         <item>
             <key> <string>_function</string> </key>
-            <value> <string>Field_getDefaultValue</string> </value>
+            <value> <string>Base_aqBase</string> </value>
         </item>
         <item>
             <key> <string>_module</string> </key>
@@ -16,7 +16,7 @@
         </item>
         <item>
             <key> <string>id</string> </key>
-            <value> <string>Field_getDefaultValue</string> </value>
+            <value> <string>Base_aqBase</string> </value>
         </item>
         <item>
             <key> <string>title</string> </key>
diff --git a/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Base_aqSelf.xml b/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Base_aqSelf.xml
new file mode 100644
index 0000000000..3388b51894
--- /dev/null
+++ b/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/Base_aqSelf.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0"?>
+<ZopeData>
+  <record id="1" aka="AAAAAAAAAAE=">
+    <pickle>
+      <global name="ExternalMethod" module="Products.ExternalMethod.ExternalMethod"/>
+    </pickle>
+    <pickle>
+      <dictionary>
+        <item>
+            <key> <string>_function</string> </key>
+            <value> <string>Base_aqSelf</string> </value>
+        </item>
+        <item>
+            <key> <string>_module</string> </key>
+            <value> <string>HalStyle</string> </value>
+        </item>
+        <item>
+            <key> <string>id</string> </key>
+            <value> <string>Base_aqSelf</string> </value>
+        </item>
+        <item>
+            <key> <string>title</string> </key>
+            <value> <string></string> </value>
+        </item>
+      </dictionary>
+    </pickle>
+  </record>
+</ZopeData>
diff --git a/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/ERP5Document_getHateoas.py b/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/ERP5Document_getHateoas.py
index 54b4447a98..797b7bea1b 100644
--- a/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/ERP5Document_getHateoas.py
+++ b/bt5/erp5_hal_json_style/SkinTemplateItem/portal_skins/erp5_hal_json_style/ERP5Document_getHateoas.py
@@ -117,7 +117,7 @@ def selectKwargsForCallable(func, initial_kwargs, kwargs_dict):
 
 
 def getUidAndAccessorForAnything(search_result, result_index, traversed_document):
-  """Return unique ID, unique URL, getter and hasser for any combination of `search_result` and `index`.
+  """Return unique ID, unique URL, getter for any combination of `search_result` and `index`.
 
   You want to use this method when you need a unique reference to random object in iterable (for example
   result of list_method or stat_method). This will give you UID and URL for identification within JIO and
@@ -126,9 +126,7 @@ def getUidAndAccessorForAnything(search_result, result_index, traversed_document
   Usage::
 
   for i, random_object in enumerate(unknown_iterable):
-    uid, url, getter, hasser = object_ids_and_access(random_object, i)
-    if hasser(random_object, "linkable"):
-      result[uid] = {'url': portal.abolute_url() + url}
+    uid, url, getter = object_ids_and_access(random_object, i)
     value = getter(random_object, "value")
   """
   if hasattr(search_result, "getObject"):
@@ -138,22 +136,14 @@ def getUidAndAccessorForAnything(search_result, result_index, traversed_document
     contents_relative_url = getRealRelativeUrl(search_result)
     # get property in secure way from documents
     search_property_getter = getProtectedProperty
-    def search_property_hasser (doc, attr):
-      """Brains cannot access Properties - they use permissioned getters."""
-      try:
-        return doc.hasProperty(attr)
-      except (AttributeError, Unauthorized) as e:
-        log('Cannot state ownership of property "{}" on {!s} because of "{!s}"'.format(
-          attr, doc, e))
-        return False
-  elif hasattr(search_result, "aq_self"):
+  elif hasattr(search_result, 'hasProperty') and hasattr(search_result, 'getId'):
     # Zope products have at least ID thus we work with that
     contents_uid = search_result.uid
     # either we got a document with relativeUrl or we got product and use ID
     contents_relative_url = getRealRelativeUrl(search_result) or search_result.getId()
     # documents and products have the same way of accessing properties
     search_property_getter = getProtectedProperty
-    search_property_hasser = lambda doc, attr: doc.hasProperty(attr)
+    # search_property_hasser = lambda doc, attr: doc.hasProperty(attr)
   else:
     # In case of reports the `search_result` can be list of
     # PythonScripts.standard._Object - a reimplementation of plain dictionary
@@ -167,13 +157,12 @@ def getUidAndAccessorForAnything(search_result, result_index, traversed_document
     contents_relative_url = "{}/{}".format(traversed_document.getRelativeUrl(), contents_uid)
     # property getter must be simple __getattr__ implementation
     search_property_getter = lambda obj, attr: getattr(obj, attr, None)
-    search_property_hasser = lambda obj, attr: hasattr(obj, attr)
 
-  return contents_uid, contents_relative_url, search_property_getter, search_property_hasser
+  return contents_uid, contents_relative_url, search_property_getter
 
 
-def getAttrFromAnything(search_result, select, search_property_getter, search_property_hasser, kwargs):
-  """Given `search_result` extract value named `select` using helper getter/hasser.
+def getAttrFromAnything(search_result, select, search_property_getter, kwargs):
+  """Given `search_result` extract value named `select` using helper getter.
 
   :param search_result: any dict-like object (usually dict or Brain or Document)
   :param select: field name (can represent actual attributes, Properties or even Scripts)
@@ -201,43 +190,46 @@ def getAttrFromAnything(search_result, select, search_property_getter, search_pr
     # or obvious getter (starts with "get" or Capital letter - Script)
     accessor_name = select
 
-  # 1. resolve attribute on a raw object (all wrappers removed) using
-  # lowest-level secure getattr method given object type
-  raw_search_result = search_result
-  if hasattr(search_result, 'aq_base'):
-    raw_search_result = search_result.aq_base
-  # BUT! only if there is no accessor (because that is the prefered way)
-  if search_property_hasser(raw_search_result, select) and not hasattr(raw_search_result, accessor_name):
-    contents_value = search_property_getter(raw_search_result, select)
+  # Following value resolution is copied from product/ERP5Form/ListBox.py#L2223
+  # 1. resolve attribute on unwrapped object using getter
+  try:
+    unwrapped_search_result = search_result.Base_aqSelf()
+    if contents_value is None and unwrapped_search_result is not None:
+      try:
+        if getattr(unwrapped_search_result, select, None) is not None:
+          # I know this looks suspicious but product/ERP5Form/ListBox.py#L2224
+          contents_value = getattr(search_result, select)
+      except Unauthorized:
+        pass
+  except AttributeError:
+    pass  # search_result is not a Document
 
   # 2. use the fact that wrappers (brain or acquisition wrapper) use
   # permissioned getters
-  unwrapped_search_result = search_result
-  if hasattr(search_result, 'aq_self'):
-    unwrapped_search_result = search_result.aq_self
+  try:
+    raw_search_result = search_result.Base_aqBase()
+    if contents_value is None and raw_search_result is not None:
+      try:
+        if getattr(raw_search_result, accessor_name, None) is not None:
+          contents_value = getattr(search_result, accessor_name, None)
+      except Unauthorized:
+        pass
+  except AttributeError:
+    pass  # search_result is not a Document
 
+  # Following part resolves values on other objects than Documents
+  # Prefer getter (accessor) than raw property name
   if contents_value is None:
-    # again we check on a unwrapped object to avoid acquisition resolution
-    # which would certainly find something which we don't want
     try:
-      if hasattr(raw_search_result, accessor_name) and callable(getattr(search_result, accessor_name)):
-        # test on raw object but get the actual accessor using wrapper and acquisition
-        # do not call it here - it will be done later in generic call part
-        contents_value = getattr(search_result, accessor_name)
-    except (AttributeError, KeyError, Unauthorized) as error:
-      log("Could not evaluate {} nor {} on {} with error {!s}".format(
-        select, accessor_name, search_result, error), level=100)  # WARNING
-
-  if contents_value is None and search_property_hasser(search_result, select):
-    # maybe it is just a attribute
-    contents_value = search_property_getter(search_result, select)
+      contents_value = getattr(search_result, accessor_name)
+    except (AttributeError, Unauthorized):
+      pass
 
   if contents_value is None:
     try:
-      contents_value = getattr(search_result, select, None)
-    except (Unauthorized, AttributeError, KeyError) as error:
-      log("Cannot resolve {} on {!s} because {!s}".format(
-        select, raw_search_result, error), level=100)
+      contents_value = search_property_getter(search_result, select)
+    except (AttributeError, Unauthorized):
+      pass
 
   if callable(contents_value):
     has_mandatory_param = False
@@ -1430,7 +1422,7 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
       # we can render fields which need 'here' to be set to currently rendered document
       #REQUEST.set('here', search_result)
       contents_item = {}
-      contents_uid, contents_relative_url, property_getter, property_hasser = \
+      contents_uid, contents_relative_url, property_getter = \
         getUidAndAccessorForAnything(search_result, result_index, traversed_document)
 
       # _links.self.href is mandatory for JIO so it can create reference to the
@@ -1453,20 +1445,21 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
           'value': contents_uid
         }
 
+      # if default value is given by evaluating Tales expression then we only
+      # put "cell" to request (expected by tales) and let the field evaluate
+      REQUEST.set('cell', search_result)
       for select in select_list:
         default_field_value = None
         # every `select` can have a template field or be just a exotic getter for a value
         if editable_field_dict.has_key(select):
           # cell has a Form Field template thus render it using the field
           # fields are nice because they are standard
-          REQUEST.set('cell', search_result)
-          # if default value is given by evaluating Tales expression then we only
-          # put "cell" to request (expected by tales) and let the field evaluate
-          if (not editable_field_dict[select].get_tales("default") and  # no tales
-              (not hasattr(editable_field_dict[select], "get_recursive_tales") or  # no recursive tales
+          if (not editable_field_dict[select].tales.get("default", "") and  # no tales
+              (editable_field_dict[select].meta_type != 'ProxyField' or
+               not hasattr(editable_field_dict[select], "get_recursive_tales") or  # no recursive tales
                editable_field_dict[select].get_recursive_tales("default") == "")):
             # if there is no tales expr (or is empty) we extract the value from search result
-            default_field_value = getAttrFromAnything(search_result, select, property_getter, property_hasser, {})
+            default_field_value = getAttrFromAnything(search_result, select, property_getter, {})
 
           contents_item[select] = renderField(
             traversed_document,
@@ -1475,13 +1468,13 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
             value=default_field_value,
             key='field_%s_%s' % (editable_field_dict[select].id, contents_uid))
 
-          REQUEST.other.pop('cell', None)
         else:
           # most of the complicated magic happens here - we need to resolve field names
           # given search_result. This name can unfortunately mean almost anything from
           # a key name to Python Script with variable number of input parameters.
-          contents_item[select] = getAttrFromAnything(search_result, select, property_getter, property_hasser, {'brain': search_result})
+          contents_item[select] = getAttrFromAnything(search_result, select, property_getter, {'brain': search_result})
       # endfor select
+      REQUEST.other.pop('cell', None)
       contents_list.append(contents_item)
     result_dict['_embedded']['contents'] = contents_list
 
diff --git a/bt5/erp5_ui_test/SkinTemplateItem/portal_skins/erp5_ui_test/FooModule_listAsContext.py b/bt5/erp5_ui_test/SkinTemplateItem/portal_skins/erp5_ui_test/FooModule_listAsContext.py
new file mode 100644
index 0000000000..fa842b1e03
--- /dev/null
+++ b/bt5/erp5_ui_test/SkinTemplateItem/portal_skins/erp5_ui_test/FooModule_listAsContext.py
@@ -0,0 +1,16 @@
+"""Test for bug 20171215-133705C
+
+acContext creates a temporary copy of Document with changed Properties.
+In related test we ensure that property is resolved before getter because
+that is how it works in the old UI.
+"""
+foo_list = list(context.contentValues())
+foo_context_list = [foo.asContext(state="Couscous") for foo in foo_list]
+
+"""
+for foo, foo_context in zip(foo_list, foo_context_list):
+  context.log("Foo.state {!s}, Foo.getState() {!s}, Foo.asContext().state {!s}, Foo.asContext().getState() {!s}".format(
+    foo.getProperty('state'), foo.getState(), getattr(foo_context, 'state'), foo_context.getState()))
+"""
+
+return foo_context_list
diff --git a/bt5/erp5_ui_test/SkinTemplateItem/portal_skins/erp5_ui_test/FooModule_listAsContext.xml b/bt5/erp5_ui_test/SkinTemplateItem/portal_skins/erp5_ui_test/FooModule_listAsContext.xml
new file mode 100644
index 0000000000..d6510e616a
--- /dev/null
+++ b/bt5/erp5_ui_test/SkinTemplateItem/portal_skins/erp5_ui_test/FooModule_listAsContext.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0"?>
+<ZopeData>
+  <record id="1" aka="AAAAAAAAAAE=">
+    <pickle>
+      <global name="PythonScript" module="Products.PythonScripts.PythonScript"/>
+    </pickle>
+    <pickle>
+      <dictionary>
+        <item>
+            <key> <string>Script_magic</string> </key>
+            <value> <int>3</int> </value>
+        </item>
+        <item>
+            <key> <string>_bind_names</string> </key>
+            <value>
+              <object>
+                <klass>
+                  <global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
+                </klass>
+                <tuple/>
+                <state>
+                  <dictionary>
+                    <item>
+                        <key> <string>_asgns</string> </key>
+                        <value>
+                          <dictionary>
+                            <item>
+                                <key> <string>name_container</string> </key>
+                                <value> <string>container</string> </value>
+                            </item>
+                            <item>
+                                <key> <string>name_context</string> </key>
+                                <value> <string>context</string> </value>
+                            </item>
+                            <item>
+                                <key> <string>name_m_self</string> </key>
+                                <value> <string>script</string> </value>
+                            </item>
+                            <item>
+                                <key> <string>name_subpath</string> </key>
+                                <value> <string>traverse_subpath</string> </value>
+                            </item>
+                          </dictionary>
+                        </value>
+                    </item>
+                  </dictionary>
+                </state>
+              </object>
+            </value>
+        </item>
+        <item>
+            <key> <string>_params</string> </key>
+            <value> <string>**kwargs</string> </value>
+        </item>
+        <item>
+            <key> <string>id</string> </key>
+            <value> <string>FooModule_listAsContext</string> </value>
+        </item>
+      </dictionary>
+    </pickle>
+  </record>
+</ZopeData>
diff --git a/bt5/erp5_web_renderjs_ui_test/PathTemplateItem/portal_tests/renderjs_ui_listbox_zuite/testListboxValueAsContext.xml b/bt5/erp5_web_renderjs_ui_test/PathTemplateItem/portal_tests/renderjs_ui_listbox_zuite/testListboxValueAsContext.xml
new file mode 100644
index 0000000000..96de392a93
--- /dev/null
+++ b/bt5/erp5_web_renderjs_ui_test/PathTemplateItem/portal_tests/renderjs_ui_listbox_zuite/testListboxValueAsContext.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0"?>
+<ZopeData>
+  <record id="1" aka="AAAAAAAAAAE=">
+    <pickle>
+      <global name="ZopePageTemplate" module="Products.PageTemplates.ZopePageTemplate"/>
+    </pickle>
+    <pickle>
+      <dictionary>
+        <item>
+            <key> <string>_bind_names</string> </key>
+            <value>
+              <object>
+                <klass>
+                  <global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
+                </klass>
+                <tuple/>
+                <state>
+                  <dictionary>
+                    <item>
+                        <key> <string>_asgns</string> </key>
+                        <value>
+                          <dictionary>
+                            <item>
+                                <key> <string>name_subpath</string> </key>
+                                <value> <string>traverse_subpath</string> </value>
+                            </item>
+                          </dictionary>
+                        </value>
+                    </item>
+                  </dictionary>
+                </state>
+              </object>
+            </value>
+        </item>
+        <item>
+            <key> <string>content_type</string> </key>
+            <value> <string>text/html</string> </value>
+        </item>
+        <item>
+            <key> <string>expand</string> </key>
+            <value> <int>0</int> </value>
+        </item>
+        <item>
+            <key> <string>id</string> </key>
+            <value> <string>testListboxValueAsContext</string> </value>
+        </item>
+        <item>
+            <key> <string>output_encoding</string> </key>
+            <value> <string>utf-8</string> </value>
+        </item>
+        <item>
+            <key> <string>title</string> </key>
+            <value> <unicode></unicode> </value>
+        </item>
+      </dictionary>
+    </pickle>
+  </record>
+</ZopeData>
diff --git a/bt5/erp5_web_renderjs_ui_test/PathTemplateItem/portal_tests/renderjs_ui_listbox_zuite/testListboxValueAsContext.zpt b/bt5/erp5_web_renderjs_ui_test/PathTemplateItem/portal_tests/renderjs_ui_listbox_zuite/testListboxValueAsContext.zpt
new file mode 100644
index 0000000000..7e8968f8d2
--- /dev/null
+++ b/bt5/erp5_web_renderjs_ui_test/PathTemplateItem/portal_tests/renderjs_ui_listbox_zuite/testListboxValueAsContext.zpt
@@ -0,0 +1,56 @@
+<html xmlns:tal="http://xml.zope.org/namespaces/tal"
+      xmlns:metal="http://xml.zope.org/namespaces/metal">
+<head>
+<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+<title>Test ListBox propetry resolution before getter</title>
+</head>
+<body>
+<table cellpadding="1" cellspacing="1" border="1">
+<thead>
+<tr><td rowspan="1" colspan="3">Bug 20171215-133705C - property should be resolved before getter if different and not empty</td></tr>
+</thead><tbody>
+
+<tr><td>store</td>
+  <td>https://softinst81338.host.vifib.net/erp5</td>
+  <td>base_url</td></tr>
+
+
+<tal:block metal:use-macro="here/PTZuite_CommonTemplate/macros/init" />
+<tr><td>open</td>
+    <td>${base_url}/foo_module/ListBoxZuite_reset</td><td></td></tr>
+<tr><td>assertTextPresent</td>
+    <td>Reset Successfully.</td><td></td></tr>
+
+<tr><td>open</td>
+    <td>${base_url}/foo_module/FooModule_createObjects?start:int=1&amp;num:int=2&amp;create_line:int=0</td><td></td></tr>
+<tr><td>assertTextPresent</td>
+    <td>Created Successfully.</td><td></td></tr>
+
+<tr><td>open</td>
+    <td>${base_url}/foo_module/FooModule_viewFooList/listbox/ListBox_setPropertyList?field_list_method=FooModule_listAsContext&amp;field_columns=id%7CID%0Astate%7CState</td>
+    <td></td></tr>
+<tr><td>assertTextPresent</td>
+    <td>Set Successfully.</td><td></td></tr>
+
+<tal:block metal:use-macro="here/Zuite_CommonTemplate/macros/wait_for_activities" />
+
+<!-- Shortcut for full renderjs url -->
+<tr><td>store</td>
+    <td>${base_url}/web_site_module/renderjs_runner</td>
+    <td>renderjs_url</td></tr>
+<tr><td>open</td>
+    <td>${renderjs_url}/#/foo_module</td><td></td></tr>
+
+<tal:block metal:use-macro="here/Zuite_CommonTemplateForRenderjsUi/macros/wait_for_listbox_loaded" />
+
+<!-- Test for value "Couscous" (which is obviously the correct one) instead of "current"
+     Why? Please read description in the bug 20171215-133705C. Custom list_method returns
+     a list of [Foo.asContext(state="Couscous")] and getState seems to refer to original value.
+-->
+<tr><td>assertText</td>
+    <td>//div[@data-gadget-scope="field_listbox"]//table/tbody/tr[1]/td[2]/a</td>
+    <td>Couscous</td></tr>
+
+</tbody></table>
+</body>
+</html>
\ No newline at end of file
-- 
2.30.9