- Introduced `ColumnBrowserFixture` component for testing column visibility and interactions. - Created `entity-column-configuration.ts` to define types and utility functions for managing entity columns. - Implemented Playwright tests in `columns-ui.test.py` to validate column behavior and preferences. - Added `visual-audit-volume.py` script to enhance the visual audit setup with synthetic data.
298 lines
19 KiB
Python
298 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Regresión L02.2 mediante Playwright MCP y la sesión sintética del preparador.
|
|
|
|
No inicia servicios ni escribe entidades; las preferencias de columnas son locales.
|
|
Preparar volumen con visual-audit-volume.py antes de ejecutar esta suite.
|
|
"""
|
|
import contextlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import tomllib
|
|
import unittest
|
|
from urllib.parse import quote, urlparse
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
AUDIT = runpy.run_path(str(ROOT / "scripts/visual-audit.py"))
|
|
SIZES = [(320, 640), (390, 844), (768, 1024), (1024, 768), (1366, 768), (1440, 900), (1920, 1080)]
|
|
|
|
|
|
class ColumnsUiTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
value = os.environ.get("METIS_PLAYWRIGHT_STORAGE_STATE")
|
|
if not value:
|
|
raise unittest.SkipTest("Requiere sesión sintética activa de visual-audit.py.")
|
|
state = (ROOT / value).resolve()
|
|
if not state.is_relative_to(ROOT / ".artifacts/playwright") or state.stat().st_mode & 0o077:
|
|
raise RuntimeError("Requiere sesión privada del preparador.")
|
|
report = json.loads((state.parent / "report.json").read_text())
|
|
if urlparse(report["origin"]).hostname != "localhost" or report.get("ordinaryUser") is not True:
|
|
raise RuntimeError("Requiere instalación sintética local ordinaria.")
|
|
cls.origin = report["origin"]
|
|
cls.company = "/companies/" + report["companyId"]
|
|
cls.output = ROOT / ".artifacts/playwright/l02-2/after"
|
|
cls.output.mkdir(parents=True, exist_ok=True)
|
|
cls.results = []
|
|
cls.measurements = cls.output / ("measurements-" + str(os.getpid()) + ".json")
|
|
cls.stack = contextlib.ExitStack()
|
|
cls.addClassCleanup(cls.stack.close)
|
|
config = tomllib.loads((ROOT / ".codex/config.toml").read_text())["mcp_servers"]["playwright"]
|
|
cls.client = cls.stack.enter_context(AUDIT["browser"](config, state))
|
|
if cls.code("return (await page.request.get(" + json.dumps(cls.origin + "/api/companies") + ")).status();") != 200:
|
|
raise RuntimeError("La sesión está caducada.")
|
|
cls.code("page.blockedWrites=[];await page.route('**/api/**',r=>{if(['GET','HEAD','OPTIONS'].includes(r.request().method()))return r.continue();page.blockedWrites.push(r.request().method());return r.abort()});return true;")
|
|
|
|
@classmethod
|
|
def code(cls, body):
|
|
result = AUDIT["tool"](cls.client, "browser_run_code_unsafe", {"code": "async(page)=>{" + body + "}"})
|
|
return AUDIT["result_json"](result)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
console = AUDIT["tool"](cls.client, "browser_console_messages", {"level": "warning"})
|
|
(cls.output / ("console-" + str(os.getpid()) + ".json")).write_text(json.dumps(console, ensure_ascii=False, indent=2) + "\n")
|
|
(cls.output / ("blocked-writes-" + str(os.getpid()) + ".json")).write_text(json.dumps(cls.code("return page.blockedWrites;")) + "\n")
|
|
|
|
def settle(self):
|
|
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))});return true;")
|
|
|
|
def navigate(self, section="assets"):
|
|
self.code("await page.evaluate(()=>window.columnTestRoot?.unmount());return true;")
|
|
AUDIT["tool"](self.client, "browser_navigate", {"url": self.origin + self.company + "/" + section})
|
|
self.code("await page.locator('[data-slot=workspace]').waitFor();return true;")
|
|
self.settle()
|
|
|
|
def resize(self, width, height):
|
|
AUDIT["tool"](self.client, "browser_resize", {"width": width, "height": height})
|
|
self.settle()
|
|
|
|
def open(self, english=False):
|
|
self.code("await page.getByRole('button',{name:" + json.dumps("Columns" if english else "Columnas") + ",exact:true}).last().click();await page.getByRole('dialog').last().getByRole('textbox').waitFor();return true;")
|
|
self.settle()
|
|
|
|
def close(self):
|
|
self.code("await page.keyboard.press('Escape');return true;")
|
|
self.settle()
|
|
|
|
def search(self, value):
|
|
self.code("await page.getByRole('dialog').last().getByRole('textbox').fill(" + json.dumps(value) + ");return true;")
|
|
self.settle()
|
|
|
|
def record(self, name, result):
|
|
self.results.append({"scenario": name, "result": result})
|
|
self.measurements.write_text(json.dumps(self.results, ensure_ascii=False, indent=2) + "\n")
|
|
|
|
def capture(self, name):
|
|
AUDIT["capture"](self.client, self.output, name)
|
|
snapshot = AUDIT["tool"](self.client, "browser_snapshot", {})
|
|
(self.output / (name + ".txt")).write_text("\n".join(item["text"] for item in snapshot["content"] if item.get("type") == "text"))
|
|
|
|
def geometry(self):
|
|
return self.code("""return page.getByRole('dialog').last().evaluate(d=>{
|
|
const b=d.getBoundingClientRect(),s=d.querySelector('[data-slot=form-body]'),r=s.getBoundingClientRect(),f=d.querySelector('[data-slot=dialog-footer]').getBoundingClientRect(),q=d.querySelector('input[data-quick-search]').getBoundingClientRect();
|
|
const options=[...d.querySelectorAll('label:has([role=checkbox])')];
|
|
return {viewport:[innerWidth,innerHeight],height:b.height,width:b.width,scrollClient:s.clientHeight,scrollHeight:s.scrollHeight,visible:d.querySelectorAll('[role=checkbox][aria-checked=true]').length,columns:options.length,groups:[...d.querySelectorAll('legend')].map(e=>e.textContent),protected:[...d.querySelectorAll('[role=checkbox][aria-disabled=true]')].map(e=>e.parentElement.textContent),focusSearch:document.activeElement.hasAttribute('data-quick-search'),searchVisible:q.top>=b.top&&q.bottom<=b.bottom,footerVisible:f.top>=b.top&&f.bottom<=b.bottom,fullOptions:options.filter(e=>{const x=e.getBoundingClientRect();return x.top>=r.top&&x.bottom<=r.bottom}).length,overflow:document.documentElement.scrollWidth>innerWidth,preference:localStorage.getItem('asset-inventory-columns')};
|
|
});""")
|
|
|
|
def fixture(self, locale="es"):
|
|
self.navigate()
|
|
module = "/@fs/" + quote(str(ROOT / "packages/rdx-ui/src/column-browser-fixture.tsx"), safe="/")
|
|
self.code("await page.evaluate(async({module,locale})=>{const target=document.createElement('div');document.body.append(target);window.columnTestRoot=(await import(module)).mountColumnFixture(target,locale)},{module:" + json.dumps(module) + ",locale:" + json.dumps(locale) + "});return true;")
|
|
self.settle()
|
|
|
|
def test_01_actual_catalog_and_resolutions(self):
|
|
self.navigate()
|
|
self.code("await page.evaluate(()=>localStorage.removeItem('asset-inventory-columns'));await page.reload();return true;")
|
|
self.open()
|
|
for width, height in SIZES:
|
|
self.resize(width, height)
|
|
result = self.geometry()
|
|
self.record("assets-" + str(width), result)
|
|
self.assertEqual(result["columns"], 75)
|
|
self.assertEqual(result["visible"], 8)
|
|
self.assertEqual(len(result["groups"]), 5)
|
|
self.assertEqual(len(result["protected"]), 4)
|
|
self.assertTrue(result["searchVisible"] and result["footerVisible"])
|
|
self.assertGreater(result["scrollClient"], 160)
|
|
self.assertGreaterEqual(result["fullOptions"], 2)
|
|
self.assertFalse(result["overflow"])
|
|
self.capture("assets-" + str(width))
|
|
self.close()
|
|
|
|
def test_02_search_does_not_change_visibility(self):
|
|
self.resize(320, 640)
|
|
self.navigate()
|
|
self.open()
|
|
before = self.geometry()
|
|
for query, count in [(" ULTIMA MODIFICACION ", 1), ("Portátil", None), ("zzzz-no-existe", 0)]:
|
|
self.search(query)
|
|
result = self.geometry()
|
|
self.record("search-" + query.strip(), result)
|
|
if count is not None:
|
|
self.assertEqual(result["columns"], count)
|
|
else:
|
|
self.assertGreater(result["columns"], 0)
|
|
self.assertEqual(result["groups"], ["Características del catálogo"])
|
|
self.assertEqual(result["preference"], before["preference"])
|
|
self.assertEqual(result["height"], before["height"])
|
|
self.capture("search-" + ("none" if count == 0 else "keyword" if count is None else "accent"))
|
|
self.code("await page.getByRole('button',{name:'Limpiar búsqueda de columnas',exact:true}).click();return true;")
|
|
self.assertEqual(self.geometry()["columns"], 75)
|
|
self.assertTrue(self.geometry()["focusSearch"])
|
|
self.search("estado")
|
|
self.close()
|
|
self.open()
|
|
self.assertEqual(self.code("return page.getByRole('dialog').getByRole('textbox').inputValue();"), "")
|
|
self.close()
|
|
|
|
def test_03_preferences_restore_and_reload(self):
|
|
self.resize(1440, 900)
|
|
self.navigate()
|
|
self.code("await page.evaluate(()=>{localStorage.setItem('asset-inventory-columns',JSON.stringify({name:false,site:false,notes:true,unknown:true}));localStorage.setItem('columns-unrelated',JSON.stringify({keep:false}))});await page.reload();return true;")
|
|
self.open()
|
|
self.assertTrue(self.code("return page.getByRole('checkbox',{name:'Nombre',exact:true}).isChecked();"))
|
|
self.assertFalse(self.code("return page.getByRole('checkbox',{name:'Sede',exact:true}).isChecked();"))
|
|
self.assertTrue(self.code("return page.getByRole('checkbox',{name:'Notas',exact:true}).isChecked();"))
|
|
self.search("Sede")
|
|
self.code("await page.getByRole('checkbox',{name:'Sede',exact:true}).check();return true;")
|
|
self.close()
|
|
self.assertEqual(self.code("return page.getByRole('columnheader',{name:'Sede',exact:true}).count();"), 1)
|
|
self.code("await page.reload();return true;")
|
|
self.open()
|
|
self.assertTrue(self.code("return page.getByRole('checkbox',{name:'Sede',exact:true}).isChecked();"))
|
|
self.code("await page.getByRole('button',{name:'Restaurar columnas',exact:true}).click();return true;")
|
|
self.record("restored", self.geometry())
|
|
self.assertEqual(self.geometry()["visible"], 8)
|
|
self.assertEqual(self.code("return page.evaluate(()=>localStorage.getItem('asset-inventory-columns'));"), "{}")
|
|
self.assertEqual(self.code("return page.evaluate(()=>localStorage.getItem('columns-unrelated'));"), '{"keep":false}')
|
|
self.capture("restored-desktop")
|
|
self.close()
|
|
self.code("await page.reload();return true;")
|
|
self.open()
|
|
self.assertEqual(self.geometry()["visible"], 8)
|
|
self.assertFalse(self.code("return page.getByRole('checkbox',{name:'Notas',exact:true}).isChecked();"))
|
|
self.close()
|
|
|
|
def test_04_three_columns_dynamic_and_storage_blocked(self):
|
|
self.resize(390, 844)
|
|
self.fixture()
|
|
self.open()
|
|
self.assertEqual(self.geometry()["columns"], 3)
|
|
self.assertEqual(self.geometry()["groups"], [])
|
|
self.assertEqual(self.geometry()["visible"], 2)
|
|
self.capture("simple-three-columns")
|
|
self.search("revision TECNICA")
|
|
self.assertEqual(self.geometry()["columns"], 1)
|
|
self.code("await page.getByRole('checkbox',{name:'Notas',exact:true}).check();return true;")
|
|
self.close()
|
|
self.code("await page.getByRole('button',{name:'Añadir campo',exact:true}).click();return true;")
|
|
self.open()
|
|
self.assertEqual(self.geometry()["columns"], 4)
|
|
self.assertFalse(self.code("return page.getByRole('checkbox',{name:'Campo nuevo',exact:true}).isChecked();"))
|
|
self.code("await page.getByRole('checkbox',{name:'Campo nuevo',exact:true}).check();await page.getByRole('button',{name:'Restaurar columnas',exact:true}).click();return true;")
|
|
self.assertEqual(self.geometry()["visible"], 2)
|
|
self.close()
|
|
self.code("await page.getByRole('button',{name:'Retirar campo',exact:true}).click();return true;")
|
|
self.open()
|
|
self.assertEqual(self.geometry()["columns"], 2)
|
|
self.close()
|
|
self.code("await page.addInitScript(()=>{const get=Storage.prototype.getItem,set=Storage.prototype.setItem;Storage.prototype.getItem=function(k){if(k==='columns-test-current')throw Error('blocked');return get.call(this,k)};Storage.prototype.setItem=function(k,v){if(k==='columns-test-current')throw Error('blocked');return set.call(this,k,v)}});return true;")
|
|
self.fixture()
|
|
self.open()
|
|
self.code("await page.getByRole('checkbox',{name:'Notas',exact:true}).check();await page.getByRole('button',{name:'Restaurar columnas',exact:true}).click();return true;")
|
|
self.assertEqual(self.geometry()["visible"], 2)
|
|
self.record("storage-blocked", self.geometry())
|
|
self.close()
|
|
|
|
def test_05_keyboard_focus_and_english(self):
|
|
self.resize(320, 640)
|
|
self.fixture("en")
|
|
self.open(english=True)
|
|
self.assertTrue(self.geometry()["focusSearch"])
|
|
self.code("await page.keyboard.press('Tab');return true;")
|
|
self.assertTrue(self.code("return page.evaluate(()=>document.activeElement.textContent==='Restore columns');"))
|
|
self.code("await page.keyboard.press('Enter');await page.keyboard.press('Tab');await page.keyboard.press('Space');return true;")
|
|
self.assertEqual(self.geometry()["visible"], 3)
|
|
self.code("await page.keyboard.press('Shift+Tab');await page.keyboard.press('Enter');return true;")
|
|
self.assertEqual(self.geometry()["visible"], 2)
|
|
self.capture("english-keyboard-320")
|
|
self.close()
|
|
self.assertTrue(self.code("return page.evaluate(()=>document.activeElement.textContent==='Columns');"))
|
|
self.open(english=True)
|
|
self.search("zzzz")
|
|
self.assertEqual(self.code("return page.getByRole('status').last().innerText();"), "No matching columns. Change or clear the search.")
|
|
self.capture("english-empty-320")
|
|
self.close()
|
|
|
|
def test_06_simple_consumers_and_nested_picker(self):
|
|
self.resize(1024, 768)
|
|
for section in ["resources", "people"]:
|
|
self.navigate(section)
|
|
self.open()
|
|
result = self.geometry()
|
|
self.record(section, result)
|
|
self.assertGreaterEqual(result["columns"], 3)
|
|
self.assertEqual(result["groups"], [])
|
|
self.assertTrue(result["focusSearch"])
|
|
self.code("await page.getByRole('button',{name:'Restaurar columnas',exact:true}).click();return true;")
|
|
self.capture(section + "-columns")
|
|
self.close()
|
|
self.navigate()
|
|
module = "/@fs/" + quote(str(ROOT / "packages/rdx-ui/src/picker-browser-fixture.tsx"), safe="/")
|
|
self.code("await page.evaluate(async module=>{const target=document.createElement('div');document.body.append(target);window.columnTestRoot=(await import(module)).mountPickerFixture(target,{count:12,locale:'es',longNames:false,kind:'fields'})}," + json.dumps(module) + ");await page.getByRole('button',{name:'Seleccionar campos',exact:true}).click();return true;")
|
|
self.resize(320, 640)
|
|
self.open()
|
|
self.assertEqual(self.geometry()["columns"], 3)
|
|
self.assertEqual(self.code("return page.getByRole('dialog').last().getByRole('checkbox',{name:'Campo',exact:true}).isDisabled();"), True)
|
|
self.capture("nested-picker-columns-320")
|
|
self.close()
|
|
self.assertTrue(self.code("return page.getByRole('dialog').getByRole('button',{name:'Columnas',exact:true}).evaluate(e=>e===document.activeElement);"))
|
|
self.close()
|
|
|
|
|
|
def test_07_shared_fields_and_scroll_focus(self):
|
|
self.resize(320, 640)
|
|
self.navigate()
|
|
shared = self.code("const r=await page.request.get(" + json.dumps(self.origin + "/api/asset-catalog/" + self.company.split('/')[2]) + ");const {items}=await r.json();const templates=items.filter(i=>i.values.kind==='template');const field=items.find(i=>i.values.kind==='field'&&templates.filter(t=>t.values.fields.some(f=>f.fieldId===i.id)).length>1);return {id:field.id,name:field.values.name,templates:templates.filter(t=>t.values.fields.some(f=>f.fieldId===field.id)).map(t=>t.values.name)};")
|
|
self.open()
|
|
self.record("shared-field", shared)
|
|
selector = 'label[for$="characteristic-' + shared["id"] + '"]'
|
|
self.search(shared["name"])
|
|
self.assertEqual(self.code("return page.getByRole('dialog').locator(" + json.dumps(selector) + ").count();"), 1)
|
|
self.capture("shared-field-one-option")
|
|
for template in shared["templates"][:2]:
|
|
self.search(template)
|
|
self.assertEqual(self.code("return page.getByRole('dialog').locator(" + json.dumps(selector) + ").count();"), 1)
|
|
self.search("")
|
|
self.code("await page.getByRole('dialog').getByRole('checkbox').last().focus();return true;")
|
|
result = self.code("return page.getByRole('dialog').evaluate(d=>{const s=d.querySelector('[data-slot=form-body]'),r=s.getBoundingClientRect(),f=document.activeElement.getBoundingClientRect();return {scroll:s.scrollTop,visible:f.top>=r.top&&f.bottom<=r.bottom,focusVisible:document.activeElement.matches(':focus-visible')}});")
|
|
self.record("last-checkbox-focus", result)
|
|
self.assertGreater(result["scroll"], 0)
|
|
self.assertTrue(result["visible"] and result["focusVisible"])
|
|
self.capture("scroll-focus-last-320")
|
|
traversal = self.code("await page.getByRole('dialog').getByRole('textbox').focus();const outside=[];for(let i=0;i<85;i++){await page.keyboard.press('Tab');await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));const focus=await page.getByRole('dialog').evaluate(d=>d.contains(document.activeElement)?null:{tag:document.activeElement.tagName,role:document.activeElement.getAttribute('role'),label:document.activeElement.getAttribute('aria-label')});if(focus)outside.push({step:i,focus})}return outside;")
|
|
self.record("keyboard-traversal", traversal)
|
|
self.assertEqual(traversal, [])
|
|
self.close()
|
|
|
|
def test_08_actual_inventory_in_english(self):
|
|
self.resize(1440, 900)
|
|
self.navigate()
|
|
self.code("await page.locator('summary').click();await page.getByRole('combobox',{name:'Idioma'}).click();await page.getByRole('option',{name:'English',exact:true}).click();await page.locator('summary').click();return true;")
|
|
try:
|
|
self.open(english=True)
|
|
self.assertEqual(self.geometry()["groups"], ["General", "Status and location", "Audit", "Infrastructure", "Catalog characteristics"])
|
|
self.assertTrue(self.code("return page.getByRole('dialog').getByText('8 visible columns of 75',{exact:true}).isVisible();"))
|
|
self.capture("assets-english-1440")
|
|
self.resize(320, 640)
|
|
self.capture("assets-english-320")
|
|
self.close()
|
|
finally:
|
|
self.code("await page.locator('summary').click();await page.getByRole('combobox',{name:'Language'}).click();await page.getByRole('option',{name:'Español',exact:true}).click();await page.locator('summary').click();return true;")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|