feat: implement SMB folder creation and publication linking flow

This commit is contained in:
David Arranz 2026-10-26 14:55:07 +01:00
parent 0a5fb28263
commit a72d6a2ec8
15 changed files with 261 additions and 22 deletions

View File

@ -506,6 +506,20 @@ se añade una primitiva genérica de wizard sin otros consumidores. Véase
[el comportamiento del alta](recursos-campos-y-plantillas.md#alta-de-recursos-en-tres-pasos).
### Vinculación del publicador tras el alta SMB
`ResourceForm` entrega al callback `onSaved` el UUID y el recurso confirmado por el
servidor. `ResourcesPage.onCreated` permite completar una operación posterior al alta
antes de abrir la ficha; el consumidor llama a `onComplete` al finalizarla o cancelarla.
`CompanyResources` utiliza este contrato para abrir el `PublicationEditor` de Assets
cuando la carpeta recién creada está activa y tiene capacidad `network:smb-share`.
El editor conserva la selección explícita del activo, búsqueda, paginación, errores e
invalidación de la relación existente. Informa de que la carpeta ya está guardada y ofrece
«Vincular más tarde». Reintentar la publicación no repite el alta del recurso. Las
ediciones, los recursos ordinarios y las altas inactivas siguen abriendo directamente
su ficha. Resources no importa Assets; Companies compone ambos módulos.
### Presentación compacta del alta por pasos
El alta de recursos y el alta de activos desde el inventario de compañía usan

View File

@ -129,3 +129,65 @@ Incidencias previas indicadas por el encargo: dos expectativas de texto de Compa
| `git diff --check` | Correcto. |
Los comandos PNPM se ejecutaron con `--config.verify-deps-before-run=false` para utilizar las dependencias instaladas. No se instalaron librerías ni se cambiaron lockfiles. La última adaptación de los editores de Access fija su altura y coloca `EntityHelp` mediante `FormHelp` junto al pie; conserva los mismos textos y la variante original de ayuda para los demás consumidores.
## Continuación del alta hacia el activo publicador
Al guardar una carpeta SMB nueva y activa desde Recursos de la compañía, se cierra
el alta y se abre el editor de publicación existente, con el UUID del recurso ya
confirmado. El operador selecciona expresamente un activo de esa compañía; no se
infiere a partir del host UNC ni del campo documental Servidor.
La carpeta y su publicación son dos operaciones explícitas. El editor explica que la
carpeta ya está guardada, conserva la selección ante errores y permite reintentar solo
la publicación. «Vincular más tarde», Escape o el cierre conservan la carpeta sin
publicador; la ficha mantiene la acción de vincularlo después. Las carpetas inactivas,
los demás recursos y las ediciones no abren esta continuación.
La composición pertenece a `CompanyResources`. Resources entrega el resultado del alta
mediante `onCreated` y abre la ficha cuando el consumidor completa el flujo; Assets
conserva el editor, la selección remota, los comandos y la invalidación de publicaciones.
No se añaden tablas, endpoints, migraciones ni conversiones de carpetas históricas.
Regresión reproducible con datos sintéticos y el MCP Docker fijado:
```bash
python3 scripts/visual-audit.py --network-share-publisher --check
```
El preparador utiliza exclusivamente una instalación desechable de `metis_desk_test`,
con 15 compañías y una principal con 50 personas, 51 activos, 50 perfiles, 50 puestos y
66 recursos antes del recorrido. La prueba verifica alta, selección, reintento sin
duplicados, aplazamiento, edición, recursos inactivos y ordinarios y aislamiento de
publicaciones; genera capturas a 1440, 390 y 320 px. Los resultados de cada ejecución
se guardan en `publisher-report.json` dentro del directorio local de evidencias.
### Validación de la continuación del alta (26 de octubre de 2026)
- `python3 scripts/visual-audit.py --network-share-publisher --check`: 18 comprobaciones
correctas; 15 compañías, 50 personas, 51 activos, 50 perfiles, 50 puestos y 70 recursos
finales en la principal. Teclado, retorno de foco, búsqueda, paginación, búsqueda vacía,
error simulado de publicación, reintento con persistencia real, aplazamiento, edición,
altas inactivas/ordinarias y aislamiento. Capturas inspeccionadas a 1440 y 320 px;
también se generó y comprobó ausencia de desbordamiento a 390 px. Entorno retirado.
- `pnpm --filter @metis/resources --filter @metis/assets --filter @metis/companies
--filter @metis/web typecheck`: correcto. `pnpm build`: correcto.
- Suites completas con `--test-isolation=none`: Resources 124/124; Assets 150/150;
Companies 310/312. Persisten las expectativas de «Personas (1)» y del texto de acceso
de personas en `companies-page.test.tsx`, ajenas a este cambio.
- `pnpm test`, repetido con acceso a loopback: termina con esos dos fallos de Companies.
La primera ejecución de Resources dentro del sandbox falló al abrir un puerto local;
su suite completa pasó al repetir con acceso a loopback.
- `pnpm typecheck`: falla en `packages/shadcn-ui/src/components/calendar.tsx:165`
(TS2375), fuera del cambio y sin modificar vendor. `pnpm lint` y `pnpm check` globales
fallan en archivos ajenos, incluidos ejemplos de skills y código de fotos de personas.
- Biome y ESLint dirigidos a todos los archivos TS/TSX/JSON del cambio: correctos;
sintaxis de los dos scripts Python y `git diff --check`: correctos.
`python3 scripts/visual-audit.test.py`: 10/10.
Skills consultadas: `rdx-solid` para reutilizar ownership y contratos; `ux-inputs-and-forms`
para explicar el guardado y el aplazamiento; `shadcn` para componer el editor existente;
`vercel-react-best-practices` para coordinar el avance desde el callback sin efectos ni
copias de estado remoto. Los auditores especializados referidos por la skill de formularios
no están instalados; las comprobaciones citadas son directas mediante Playwright MCP,
no una certificación de accesibilidad. No se han ejecutado suites MariaDB adicionales:
los comandos existentes se han ejercitado a través de HTTP en el entorno desechable.

View File

@ -628,6 +628,8 @@
"publisher": "Publishing asset"
},
"shareUi": {
"linkLater": "Link later",
"resourceCreated": "The share “{{name}}” has been saved. Select its publishing asset. If you leave this for later, you can link it from the share details.",
"published": "Published network shares",
"publisher": "Published by",
"publish": "Assign publication",

View File

@ -628,6 +628,8 @@
"publisher": "Activo publicador"
},
"shareUi": {
"linkLater": "Vincular más tarde",
"resourceCreated": "La carpeta «{{name}}» está guardada. Selecciona el activo que la publica. Si lo dejas para más tarde, podrás vincularlo desde la ficha de la carpeta.",
"published": "Recursos compartidos publicados",
"publisher": "Publicado por",
"publish": "Asignar publicación",

View File

@ -16,6 +16,7 @@ export {
} from "./infrastructure/shared/api/infrastructure-api";
export { getInfrastructureItems } from "./infrastructure/shared/helpers/infrastructure-items";
export { NetworkSharePublications } from "./network-share-publications/detail/ui/network-share-publications";
export { PublicationEditor } from "./network-share-publications/manage/ui/publication-editor";
export { PublicationPicker } from "./network-share-publications/select/ui/publication-picker";
export { assetsEnglish, assetsSpanish } from "./shared";
export { inventoryApi } from "./shared/api";

View File

@ -22,8 +22,10 @@ export function PublicationEditor({
companyId,
operation,
onClose,
createdResourceName,
}: {
companyId: string;
createdResourceName?: string;
operation: PublicationOperation;
onClose: () => void;
}) {
@ -95,7 +97,7 @@ export function PublicationEditor({
return (
<>
<FormDialog
cancelLabel={t("cancel")}
cancelLabel={t(createdResourceName ? "shareUi.linkLater" : "cancel")}
formActions
layout="stable"
onOpenChange={(open) => {
@ -107,6 +109,11 @@ export function PublicationEditor({
title={t(`shareUi.${operation.action}`)}
>
<FormBody>
{createdResourceName && (
<p className="text-sm" role="status">
{t("shareUi.resourceCreated", { name: createdResourceName })}
</p>
)}
{operation.action !== "end" && (
<div className="flex flex-col gap-2">
<FormLabel htmlFor={id} required>

View File

@ -1,6 +1,6 @@
import { AssetCatalogPage, InfrastructurePage } from "@metis/assets/client";
import { useBrowserSearchParams, useCreateNavigation } from "@metis/core/client";
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
import { ResourceCatalogPage } from "@metis/resources/client";
import type { ComponentProps } from "react";
import type { CompanyDTO } from "../../../../common";
@ -14,14 +14,13 @@ import { PeopleSection } from "../../../people";
import { PrinterConfigurationsPage } from "../../../printer-configurations";
import { ProcessesPage } from "../../../processes";
import { RemoteConnectionsPage } from "../../../remote-connections";
import { CompanyResourceRelatedContent } from "../../../resources/detail/ui/company-resource-related-content";
import { CompanyResources } from "../../../resources/list/ui/company-resources";
import { SiteSection } from "../../../sites";
import { TeleworkAuthorizationsPage } from "../../../telework-authorizations";
import { WorkProfilesPage } from "../../../work-profiles";
import { CatalogOverview } from "../../../workspace/catalogs/ui/catalog-overview";
import type { CompanyTab } from "../../shared/types";
import { CompanyEntityContext } from "./company-entity-context";
import { CompanyInventory } from "./company-inventory";
import { CompanyOverview } from "./company-overview";
@ -156,20 +155,12 @@ export function CompanySection({
);
case "resources":
return (
<ResourcesPage
companyId={company.id}
<CompanyResources
company={company}
initialCreate={create}
initialId={initialEntityId}
relatedContent={(resource) => (
<CompanyEntityContext
companyId={company.id}
initialFocus={false}
key={resource.id}
layoutReady={layoutReady}
>
<CompanyResourceRelatedContent company={company} resource={resource} />
</CompanyEntityContext>
)}
key={company.id}
layoutReady={layoutReady}
/>
);
case "resource-catalog":

View File

@ -0,0 +1,70 @@
import { PublicationEditor } from "@metis/assets/client";
import { ResourcesPage } from "@metis/resources/client";
import { useState } from "react";
import type { CompanyDTO } from "../../../../common";
import { CompanyEntityContext } from "../../../companies/detail/ui/company-entity-context";
import { CompanyResourceRelatedContent } from "../../detail/ui/company-resource-related-content";
export function CompanyResources({
company,
initialCreate,
initialId,
layoutReady,
}: {
company: CompanyDTO;
initialCreate?: boolean | undefined;
initialId?: string | undefined;
layoutReady: boolean;
}) {
const [publication, setPublication] = useState<{
resourceId: string;
resourceName: string;
onComplete: () => void;
}>();
return (
<>
<ResourcesPage
companyId={company.id}
initialCreate={initialCreate}
initialId={initialId}
onCreated={(resource, onComplete) => {
if (resource.networkShare?.capability === "network:smb-share" && resource.values.active) {
setPublication({
resourceId: resource.id,
resourceName: resource.values.name,
onComplete,
});
} else {
onComplete();
}
}}
relatedContent={(resource) => (
<CompanyEntityContext
companyId={company.id}
initialFocus={false}
key={resource.id}
layoutReady={layoutReady}
>
<CompanyResourceRelatedContent company={company} resource={resource} />
</CompanyEntityContext>
)}
/>
{publication && (
<PublicationEditor
companyId={company.id}
createdResourceName={publication.resourceName}
onClose={() => {
setPublication(undefined);
publication.onComplete();
}}
operation={{
action: "publish",
anchor: { type: "resources:resource", id: publication.resourceId },
}}
/>
)}
</>
);
}

View File

@ -4,6 +4,8 @@ import type { ResourceDTO } from "../../../../common";
export type ResourcesPageProps = {
companyId: string;
/** Completa el flujo posterior al alta antes de abrir la ficha del recurso guardado. */
onCreated?: (resource: ResourceDTO, onComplete: () => void) => void;
relatedContent?: (resource: ResourceDTO) => ReactNode;
initialId?: string | undefined;
initialCreate?: boolean | undefined;

View File

@ -27,7 +27,7 @@ import { ResourceInventoryTable } from "./resource-inventory-table";
export function ResourcesPage(props: ResourcesPageProps) {
const controller = useResourcesPageController(props);
const { companyId, relatedContent } = props;
const { companyId, relatedContent, onCreated } = props;
const {
editor,
setEditor,
@ -213,9 +213,13 @@ export function ResourcesPage(props: ResourcesPageProps) {
initialKind={creationKind}
items={catalog.data.items}
onClose={() => setEditor(undefined)}
onSaved={(id) => {
onSaved={(id, resource) => {
setEditor(undefined);
selectResource(id);
if (editor === "new" && onCreated) {
onCreated(resource, () => selectResource(id));
} else {
selectResource(id);
}
}}
resource={edited}
/>

View File

@ -89,7 +89,7 @@ export function useResourceFormController({
)
);
if (mounted.current) {
onSaved(response.resource.id);
onSaved(response.resource.id, response.resource);
}
},
});

View File

@ -5,7 +5,7 @@ export type ResourceFormProps = {
companyId: string;
resource?: ResourceDTO | undefined;
items: readonly CatalogItemDTO[];
onSaved: (id: string) => void;
onSaved: (id: string, resource: ResourceDTO) => void;
allowQuickCreate?: boolean;
creationLayout?: "single-page" | "steps";
initialKind?: "mailbox" | "access" | "application" | "certificate" | "support" | "os" | undefined;

View File

@ -19,7 +19,7 @@ export function ResourceEditorDialog({
items: ComponentProps<typeof ResourceForm>["items"];
onClose: () => void;
finalFocus?: ComponentProps<typeof FormDialog>["finalFocus"];
onSaved: (id: string) => void;
onSaved: ComponentProps<typeof ResourceForm>["onSaved"];
initialKind?: ComponentProps<typeof ResourceForm>["initialKind"];
}) {
const { t } = useTranslation("resources");

View File

@ -0,0 +1,78 @@
"""Alta SMB y publicación: regresión con HTTP real en el entorno visual desechable."""
import json
def verify(context, fixture, admin_password):
tool, browser, capture = context['tool'], context['browser'], context['capture']
origin, directory = context['origin'], context['directory']
company = fixture['company']
base = origin + '/companies/' + company + '/resources'
screenshots, checks = [], []
def code(client, source):
result = context['result_json'](tool(client, 'browser_run_code_unsafe', {
'code': 'async (page) => { page.setDefaultTimeout(15000); try { ' + source + ' return true; } catch(error) { return {failure: String(error.message)}; } }'}))
if isinstance(result, dict) and 'failure' in result:
raise RuntimeError(result['failure'])
return result
def create(client, name, active=True, smb=True):
print('Publicador SMB: ' + name, flush=True)
tool(client, 'browser_navigate', {'url': base})
code(client, "await page.getByRole('button',{name:'Nuevo recurso',exact:true}).click(); await page.getByRole('combobox',{name:'Plantilla de recurso',exact:true}).click(); await page.getByRole('option',{name:" + json.dumps('Carpeta compartida' if smb else 'Licencia de software') + ",exact:true}).click(); await page.getByRole('button',{name:'Siguiente',exact:true}).click();")
if smb:
unc = '\\\\alias-documental\\' + name.replace(' ', '-')
code(client, "await page.getByRole('textbox',{name:'Ruta UNC',exact:true}).fill(" + json.dumps(unc) + ");")
code(client, "await page.getByRole('button',{name:'Siguiente',exact:true}).click(); await page.getByRole('textbox',{name:'Nombre',exact:true}).fill(" + json.dumps(name) + ");")
if not active:
code(client, "await page.getByRole('combobox',{name:'Estado',exact:true}).click(); await page.getByRole('option',{name:'Inactivo',exact:true}).click();")
code(client, "await page.getByRole('button',{name:'Guardar',exact:true}).click(); await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Nuevo recurso',exact:true})}).waitFor({state:'hidden'});")
with browser(context['config']) as client:
try:
tool(client, 'browser_resize', {'width': 1440, 'height': 1000})
tool(client, 'browser_navigate', {'url': origin + '/login'})
code(client, "await page.getByRole('button',{name:'Entrar',exact:true}).waitFor();")
tool(client, 'browser_fill_form', {'fields': [
{'name': 'Correo electrónico', 'type': 'textbox', 'target': 'input[type=email]', 'value': fixture['email']},
{'name': 'Contraseña', 'type': 'textbox', 'target': 'input[type=password]', 'value': fixture['password']}]})
tool(client, 'browser_click', {'target': 'button[type=submit]'})
code(client, "await page.locator('[data-slot=workspace]').waitFor({timeout:30000});")
# Las comprobaciones y escrituras siguientes apuntan solo al contenedor sintético.
create(client, 'AUDIT-SMB Álvarez vinculado')
code(client, "await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Asignar publicación',exact:true})}).waitFor(); await page.getByRole('status').filter({hasText:'está guardada'}).waitFor(); if(!await page.getByRole('button',{name:'Confirmar',exact:true}).isDisabled())throw Error('Confirmación sin activo'); await page.getByRole('button',{name:'Seleccionar activo publicador',exact:true}).focus(); await page.keyboard.press('Enter'); await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Seleccionar activo publicador',exact:true})}).waitFor();")
checks.extend(['ordinary-user', 'create-smb-opens-publication', 'saved-resource-explained', 'no-default-publisher', 'keyboard-open-picker'])
code(client, "await page.getByRole('button',{name:'Siguiente',exact:true}).click(); await page.getByRole('button',{name:'Anterior',exact:true}).click(); await page.getByRole('textbox',{name:'Buscar',exact:true}).fill('NO-EXISTE-SMB'); await page.getByText('No hay elementos disponibles para esta búsqueda.',{exact:true}).waitFor(); await page.getByRole('textbox',{name:'Buscar',exact:true}).fill('AUDIT-R23-PORT-50'); await page.getByRole('radio',{name:'Seleccionar: AUDIT-R23-PORT-50',exact:true}).check(); await page.getByRole('button',{name:'Seleccionar',exact:true}).click(); await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Seleccionar activo publicador',exact:true})}).waitFor({state:'hidden'}); if(!await page.getByRole('button',{name:'Seleccionar activo publicador',exact:true}).evaluate(e=>e===document.activeElement))throw Error('Foco no restaurado');")
checks.extend(['asset-pagination', 'empty-search-recovery', 'asset-search', 'picker-focus-return'])
for width in [1440, 390, 320]:
tool(client, 'browser_resize', {'width': width, 'height': 900})
code(client, "if(await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Asignar publicación',exact:true})}).evaluate(e=>e.scrollWidth>e.clientWidth+1))throw Error('Desbordamiento horizontal');")
screenshots.append(capture(client, directory, 'publisher-create-' + str(width)))
tool(client, 'browser_resize', {'width': 1440, 'height': 1000})
pattern = '**/api/asset-network-share-publications/' + company
code(client, "await page.route(" + json.dumps(pattern) + ",route=>route.request().method()==='POST'?route.fulfill({status:409,contentType:'application/json',json:{code:'ASSET_INACTIVE'}}):route.continue()); await page.getByRole('button',{name:'Confirmar',exact:true}).click(); await page.getByRole('alert').waitFor(); if(await page.getByRole('button',{name:'Seleccionar activo publicador',exact:true}).textContent()!=='AUDIT-R23-PORT-50')throw Error('Selección perdida'); await page.unroute(" + json.dumps(pattern) + "); await page.getByRole('button',{name:'Confirmar',exact:true}).click(); await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Asignar publicación',exact:true})}).waitFor({state:'hidden'});")
outcome = code(client, "const resources=await (await page.request.get('" + origin + "/api/resources/" + company + "')).json(); const found=resources.resources.filter(r=>r.values.name==='AUDIT-SMB Álvarez vinculado'); if(found.length!==1)throw Error('Recurso duplicado'); const publications=await (await page.request.get('" + origin + "/api/asset-network-share-publications/" + company + "?resourceId='+found[0].id)).json(); if(publications.publications.length!==1||publications.publications[0].assetId!==" + json.dumps(fixture['softwareAssets'][-1]) + ")throw Error('Publicador incorrecto'); return {id:found[0].id};")
checks.extend(['publication-error-retains-selection', 'retry-no-duplicate-resource', 'real-publication-persisted', 'alias-does-not-infer-asset'])
tool(client, 'browser_navigate', {'url': base + '/' + outcome['id']})
code(client, "await page.getByRole('complementary').getByRole('button',{name:'Editar',exact:true}).click(); await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Editar recurso',exact:true})}).waitFor(); await page.getByRole('button',{name:'Guardar',exact:true}).click(); await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Editar recurso',exact:true})}).waitFor({state:'hidden'}); if(await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Asignar publicación',exact:true})}).count())throw Error('Edición repite alta');")
checks.append('editing-does-not-reopen-publication')
create(client, 'AUDIT-SMB pendiente')
code(client, "await page.getByRole('button',{name:'Vincular más tarde',exact:true}).click(); await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Asignar publicación',exact:true})}).waitFor({state:'hidden'}); const resources=await (await page.request.get('" + origin + "/api/resources/" + company + "')).json(); const r=resources.resources.find(r=>r.values.name==='AUDIT-SMB pendiente'); if(!r)throw Error('Recurso perdido'); const p=await (await page.request.get('" + origin + "/api/asset-network-share-publications/" + company + "?resourceId='+r.id)).json(); if(p.publications.length)throw Error('Publicación no confirmada');")
checks.append('link-later-keeps-unpublished-resource')
create(client, 'AUDIT-SMB inactivo', active=False)
code(client, "if(await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Asignar publicación',exact:true})}).count())throw Error('Alta inactiva ofrece publicación');")
create(client, 'AUDIT-LIC licencia', smb=False)
code(client, "if(await page.getByRole('dialog').filter({has:page.getByRole('heading',{name:'Asignar publicación',exact:true})}).count())throw Error('Recurso ordinario ofrece publicación');")
checks.extend(['inactive-share-skips-publication', 'ordinary-resource-skips-publication'])
code(client, "const response=await page.request.get('" + origin + "/api/asset-network-share-publications/" + fixture['otherCompanies'][0] + "'); if(!response.ok()||(await response.json()).publications.length)throw Error('Aislamiento de compañía');")
checks.append('other-company-has-no-publications')
except Exception:
capture(client, directory, 'publisher-failure')
snapshot = tool(client, 'browser_snapshot', {})
(directory / 'publisher-failure.json').write_text(json.dumps(snapshot, ensure_ascii=False))
raise
report = {'checks': checks, 'screenshots': screenshots, 'companies': 15,
'principal': {'people': 50, 'assets': 51, 'resources': 70, 'profiles': 50, 'workstations': 50},
'database': 'metis_desk_test', 'mcp': 'Docker stdio fijado'}
(directory / 'publisher-report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
print('Publicador SMB verificado: ' + str(len(checks)) + ' comprobaciones. Evidencias: ' + str(directory), flush=True)

View File

@ -301,6 +301,7 @@ def verify_browser(config, origin, fixture, directory, state, logout):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--network-share-publisher", action="store_true", help="Alta de carpeta SMB y vinculación del activo publicador con datos sintéticos")
parser.add_argument("--dialog-help", action="store_true", help="Ayuda contextual en diálogos reales con datos sintéticos y MCP")
parser.add_argument("--check", action="store_true", help="Verifica y retira el entorno; sin esta opción espera hasta Ctrl+C")
parser.add_argument("--global-applications", action="store_true", help="Recorrido R2.3 con catálogo habilitado solo tras migración desechable")
@ -349,6 +350,11 @@ def main():
"DB_NAME": "metis_desk_test", "DB_USER": "visual_audit", "DB_PASSWORD": database_password,
"SESSION_SECRET": secrets.token_urlsafe(48), "NODE_ENV": "test", "APP_ORIGIN": origin,
"PORT": str(api_port), "API_PROXY_TARGET": api.base}
if options.network_share_publisher:
runner = runpy.run_path(str(ROOT / "scripts/global-applications-ui-audit.py"))["run"]
verifier = runpy.run_path(str(ROOT / "scripts/network-share-publisher-ui-audit.py"))["verify"]
runner({**globals(), **locals()}, verifier=verifier)
return
if options.dialog_help:
runner = runpy.run_path(str(ROOT / "scripts/global-applications-ui-audit.py"))["run"]
verifier = runpy.run_path(str(ROOT / "scripts/dialog-help-ui-audit.py"))["verify"]