#!/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()