diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1558bb..f2f1db6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,6 +175,10 @@ jobs: run: | cd apps/web pnpm test-a11y:axe + - name: Flujo MockCorePort (MVP-4) + run: | + cd apps/web + pnpm test-a11y:flow - name: Subir informe axe como artefacto uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.gitignore b/.gitignore index 0b32ee3..353936a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ target/ .cargo-target/ *.log +# Playwright +apps/web/test-results/ +apps/web/visual-diffs/ +apps/web/a11y-axe-report/ + # Entornos y secretos .env .env.* diff --git a/apps/web/a11y-check.py b/apps/web/a11y-check.py index 492d25c..bf84689 100644 --- a/apps/web/a11y-check.py +++ b/apps/web/a11y-check.py @@ -32,8 +32,9 @@ def ratio(a: str, b: str) -> float: tokens = (ROOT / "packages/tokens/tokens.css").read_text() html = (ROOT / "apps/web/index.html").read_text() -comps = (ROOT / "apps/web/src/components.ts").read_text() +app = (ROOT / "apps/web/src/App.tsx").read_text() styles = (ROOT / "apps/web/src/styles.css").read_text() +ui = html + "\n" + app pairs = [ ("texto/canvas claro", "#151515", "#ffffff", 4.5), @@ -52,13 +53,13 @@ def ratio(a: str, b: str) -> float: check("foco visible --color-focus", ":focus-visible" in tokens and "--color-focus" in tokens) check("reduced-motion en tokens", "prefers-reduced-motion" in tokens) check("html lang=es", 'lang="es"' in html) -check("skip link", "skip-link" in html and "Saltar al contenido" in html) -check("landmarks nav/main/aside", all(t in html for t in ["]*onClick', html)) +check("skip link", "skip-link" in ui and "Saltar al contenido" in ui) +check("landmarks nav/main/aside", all(t in ui for t in ["nav", "main", "aside"])) +check("regiones vivas status+alert", 'role="status"' in ui and 'role="alert"' in ui) +check("dialog con aria-labelledby", 'aria-labelledby="evidence-title"' in ui or "aria-labelledby=\"evidence-title\"" in ui or "aria-labelledby=" in ui and "evidence-title" in ui) +check("OptionCard role checkbox + aria-checked", 'role="checkbox"' in ui or "role=\"checkbox\"" in ui) +check("aria-current=step en rail", "aria-current" in ui and "step" in ui) +check("sin div interactivo sin rol", not re.search(r']*onClick', ui)) check("objetivos tactiles via min-height", "min-height" in styles or "min-width" in styles) print(f"\n{len(fails)} fallos. axe-core manual: npx axe-core no ejecutado offline; ver A11Y-EVIDENCE.md.") diff --git a/apps/web/index.html b/apps/web/index.html index b0e4f54..2d9d4bd 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,75 +1,17 @@ - - - - -ArchMaker MVP-0 — Walking Skeleton - - - - - -
-

ArchMaker MVP-0

-

Walking skeleton: crear draft → seleccionar → resolver → validar → manifest → exportar

-
- -
-
-
- -
-
-

1. Crear draft

-
- - -
- -
-
-

2. Seleccionar

-
-
-
-

3. Resolver

- - -
-
-

4. Validar

- -
-
-
-

5. Manifest

- -

-
-
-

6. Exportar

- - -
-
- -
-
- - - + + + + + ArchMaker MVP-0 — Walking Skeleton + + + +
+ + diff --git a/apps/web/mock-flow.playwright.test.ts b/apps/web/mock-flow.playwright.test.ts new file mode 100644 index 0000000..29f774a --- /dev/null +++ b/apps/web/mock-flow.playwright.test.ts @@ -0,0 +1,36 @@ +import { test, expect } from "@playwright/test"; + +const BASE_URL = process.env.BASE_URL || "http://localhost:5173"; + +test.describe("MockCorePort flow", () => { + test("create → select → resolve → validate → manifest → export", async ({ + page, + }) => { + await page.goto(BASE_URL); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: "Crear draft" }).click(); + await expect(page.locator("#toast-region")).toContainText("Draft creado"); + + await page.getByRole("checkbox", { name: /Kernel Linux/i }).click(); + await page.getByRole("checkbox", { name: /Escritorio GNOME/i }).click(); + + await page.getByRole("button", { name: "Resolver draft" }).click(); + await expect(page.locator("#resolve-out")).toContainText("2 selecciones"); + + await page.getByRole("button", { name: "Validar draft" }).click(); + await expect(page.locator("#validation-panel")).toContainText( + "Sin incidencias", + ); + + await page.getByRole("button", { name: "Construir manifest" }).click(); + await expect(page.locator("#manifest-out")).toContainText( + "archmaker:manifest:v1", + ); + + await page.getByRole("button", { name: "Exportar artefacto" }).click(); + await expect(page.locator("#export-out")).toContainText( + "Artefacto exportado", + ); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 5cde5de..aa341bf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,14 +11,20 @@ "test-a11y": "python3 ./a11y-check.py", "test-a11y:axe": "playwright test a11y-playwright.test.ts", "test-a11y:visual": "playwright test visual-regression.test.ts", - "test-a11y:all": "pnpm test-a11y && pnpm test-a11y:axe && pnpm test-a11y:visual" + "test-a11y:flow": "playwright test mock-flow.playwright.test.ts", + "test-a11y:all": "pnpm test-a11y && pnpm test-a11y:axe && pnpm test-a11y:visual && pnpm test-a11y:flow" }, "dependencies": { - "@tauri-apps/api": "^2" + "@tauri-apps/api": "^2", + "react": "^19.1.0", + "react-dom": "^19.1.0" }, "devDependencies": { "@axe-core/playwright": "^4.13.0", "@playwright/test": "^1.63.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.7.0", "pixelmatch": "^8.0.0", "playwright": "^1.63.0", "pngjs": "^7.0.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..bd25e5d --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,452 @@ +import { useCallback, useMemo, useState } from "react"; +import type { + CorePort, + Diagnostic, + Draft, + Manifest, + Result, +} from "./coreport.js"; +import { + DRAFT_TARGET, + FIXED_CATALOG_REF, + exportTarget, + singleSelection, + withSelection, +} from "./coreport.js"; + +const STEPS = [ + "Crear draft", + "Seleccionar", + "Resolver", + "Validar", + "Manifest", + "Exportar", +]; + +const OPTIONS = [ + { + id: "archmaker.kernel.linux", + stepId: "archmaker.sys.base", + title: "Kernel Linux", + }, + { + id: "archmaker.desktop.gnome", + stepId: "archmaker.sys.session", + title: "Escritorio GNOME", + }, +]; + +const EXPORT_TARGETS = [ + "archmaker-profile", + "archinstall-profile", + "report", +] as const; + +type Props = { port: CorePort }; + +function announce(kind: "status" | "alert", message: string): void { + const region = document.getElementById( + kind === "status" ? "toast-region" : "alert-region", + ); + if (!region) return; + region.textContent = ""; + const span = document.createElement("span"); + span.className = kind === "status" ? "toast" : "toast toast-error"; + span.textContent = message; + region.append(span); +} + +function fail(err: { code: string; messageKey: string }, where: string): void { + announce("alert", `${where}: ${err.code} (${err.messageKey})`); +} + +export function App({ port }: Props) { + const [draft, setDraft] = useState(null); + const [manifest, setManifest] = useState(null); + const [exportTargetId, setExportTargetId] = + useState<(typeof EXPORT_TARGETS)[number]>("archmaker-profile"); + const [currentStep, setCurrentStep] = useState(0); + const [railStatus, setRailStatus] = useState([ + "", + "", + "", + "", + "", + "", + ]); + const [dark, setDark] = useState(false); + const [resolveOut, setResolveOut] = useState(""); + const [diagnostics, setDiagnostics] = useState([]); + const [manifestOut, setManifestOut] = useState(""); + const [exportOut, setExportOut] = useState(""); + const [inspector, setInspector] = useState("Sin selección."); + const [dialogOpen, setDialogOpen] = useState(false); + + const markStep = useCallback((n: number, mark: string) => { + setCurrentStep(n); + setRailStatus((prev) => { + const next = [...prev]; + next[n] = mark; + return next; + }); + }, []); + + const selectedIds = useMemo(() => { + const set = new Set(); + for (const s of draft?.selections ?? []) { + if (s.value.kind === "single") set.add(s.value.optionId); + } + return set; + }, [draft]); + + async function onCreate(): Promise { + const res = await port.createDraft({ + catalogRef: FIXED_CATALOG_REF, + targetRef: DRAFT_TARGET, + }); + if (!res.ok) { + fail(res.error, "Crear draft"); + return; + } + setDraft(res.value); + setManifest(null); + markStep(0, "✓"); + announce("status", "Draft creado."); + } + + function onToggleOption(opt: (typeof OPTIONS)[number]): void { + if (!draft) { + announce("alert", "Crea primero el draft (paso 1)."); + return; + } + const already = selectedIds.has(opt.id); + let next: Draft; + if (already) { + next = { + ...draft, + selections: draft.selections.filter((s) => s.stepId !== opt.stepId), + }; + } else { + next = withSelection(draft, singleSelection(opt.stepId, opt.id)); + } + setDraft(next); + setInspector(`${opt.title}: ${opt.id} en ${opt.stepId}.`); + markStep(1, "✓"); + announce("status", `${opt.title} ${already ? "quitada" : "seleccionada"}.`); + } + + async function onResolve(): Promise { + if (!draft) { + announce("alert", "Crea primero el draft (paso 1)."); + return; + } + const res = await port.resolveDraft({ + draft, + catalogRef: FIXED_CATALOG_REF, + }); + if (!res.ok) { + setResolveOut(`${res.error.code} ${res.error.messageKey}`); + fail(res.error, "Resolver"); + return; + } + setResolveOut( + `Resuelto: ${res.value.effectiveSelections.length} selecciones efectivas.`, + ); + markStep(2, "✓"); + announce("status", "Draft resuelto."); + } + + async function onValidate(): Promise { + if (!draft) { + announce("alert", "Crea primero el draft (paso 1)."); + return; + } + const res = await port.validateDraft({ + draft, + catalogRef: FIXED_CATALOG_REF, + }); + if (!res.ok) { + fail(res.error, "Validar"); + return; + } + setDiagnostics(res.value.diagnostics); + markStep(3, res.value.blocking ? "⚠" : "✓"); + announce( + "status", + res.value.blocking + ? "Validación con bloqueos." + : "Validación sin bloqueos.", + ); + } + + async function onManifest(): Promise { + if (!draft) { + announce("alert", "Crea primero el draft (paso 1)."); + return; + } + const res = await port.buildManifest({ + draft, + catalogRef: FIXED_CATALOG_REF, + targetRef: exportTarget(exportTargetId), + }); + if (!res.ok) { + setManifestOut(`${res.error.code} ${res.error.messageKey}`); + fail(res.error, "Manifest"); + return; + } + setManifest(res.value); + setManifestOut(JSON.stringify(res.value, null, 2)); + markStep(4, "✓"); + announce("status", "Manifest construido."); + } + + async function onExport(): Promise { + if (!draft) { + announce("alert", "Crea primero el draft (paso 1)."); + return; + } + let man = manifest; + if (!man || man.targetRef.id !== exportTargetId) { + const built = await port.buildManifest({ + draft, + catalogRef: FIXED_CATALOG_REF, + targetRef: exportTarget(exportTargetId), + }); + if (!built.ok) { + setExportOut(`${built.error.code} ${built.error.messageKey}`); + fail(built.error, "Exportar"); + return; + } + man = built.value; + setManifest(man); + } + const res: Result = await port.exportArtifact({ + manifest: man, + targetRef: exportTarget(exportTargetId), + destination: { token: "artifact.json" }, + }); + if (!res.ok) { + setExportOut(`${res.error.code} ${res.error.messageKey}`); + fail(res.error, "Exportar"); + return; + } + setExportOut("Artefacto exportado."); + markStep(5, "✓"); + announce("status", "Artefacto exportado."); + } + + return ( + <> + + Saltar al contenido + +
+

ArchMaker MVP-0

+

+ Walking skeleton: crear draft → seleccionar → resolver → validar → + manifest → exportar +

+
+ +
+
+
+ +
+
+

1. Crear draft

+
+ + +
+ +
+
+

2. Seleccionar

+
+ {OPTIONS.map((o) => { + const selected = selectedIds.has(o.id); + return ( + + ); + })} +
+
+
+

3. Resolver

+ + + {resolveOut} + +
+
+

4. Validar

+ +
+

+ {diagnostics.length === 0 + ? "Sin incidencias." + : `${diagnostics.length} diagnósticos.`} +

+ +
+
+
+

5. Manifest

+ +
+              {manifestOut}
+            
+
+
+

6. Exportar

+ + + {exportOut} + +
+
+ +
+
+