metis-desk/scripts/columns-ui.test.py
David ba034794c6 feat: add column browser fixture and entity column configuration
- 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.
2026-10-22 10:10:34 +02:00

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()