diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fb292d..898b3a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,51 @@ jobs: - run: npm run format:check - run: npm run typecheck - run: npm test + + # ALVA låg utanför pipelinen. Roten kör `npm test --workspaces`, och + # felsokning/app är inte ett workspace — så samtliga 311 tester + # passerade bara när någon körde dem för hand. + # + # Det betyder att varje garanti de två revisionerna kallar "låst av ett + # test" — härkomsten, det stängda schemat, kvalitetsgrinden, + # högvoltsspärren, ALVA-RULE-200 — i praktiken var olåst. Ett test som + # ingen kör är dokumentation, inte en spärr. + alva: + runs-on: ubuntu-latest + defaults: + run: + working-directory: felsokning/app + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: felsokning/app/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run typkontroll + - run: npm test + + # Genomgången: hela ärenden genom det byggda gränssnittet. + # + # Egen jobbrad därför att den kräver en webbläsare och tar längre tid. + # Den fångar en annan sorts fel än enhetstesterna: tre defekter i förra + # cykeln levde i ÖVERENSKOMMELSEN mellan moduler — mellan klient och + # grind, mellan metodikens krav och den evidens den producerar — och + # inget av dem syntes i en grön svit. + genomgang: + runs-on: ubuntu-latest + defaults: + run: + working-directory: felsokning/app + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: felsokning/app/package-lock.json + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run genomgang diff --git a/felsokning/app/e2e/genomgang.mjs b/felsokning/app/e2e/genomgang.mjs new file mode 100644 index 0000000..b2304a5 --- /dev/null +++ b/felsokning/app/e2e/genomgang.mjs @@ -0,0 +1,455 @@ +// Genomgång: hela ärenden genom det byggda gränssnittet. +// +// ---- Varför det här finns ----------------------------------------------- +// +// Enhetstesterna granskar modulerna. De tre allvarligaste defekterna i +// förra cykeln levde inte i någon modul utan i ÖVERENSKOMMELSEN mellan +// dem, och svit efter svit var grön medan de fanns: +// +// · Klientens avslutsvillkor hade glidit från serverns grind. Ett +// ärende gick att stänga på skärmen utan slutsats och utan kundens +// besked — och nekades först vid synk, när bilen hade åkt. +// +// · Grinden krävde textresultat även på kontroller vars evidens är ett +// foto, och hade därför nekat avslut på nästan varje riktigt ärende. +// +// · 56 av metodikernas 153 kontroller krävde ett mätvärde och +// producerade ingen mätevidens, så felorsaksanalysen nekade +// "Mätresultat" direkt efter att teknikern matat in siffran. +// +// Ingen av dem går att se utan att slutföra ett ärende. Därför den här +// filen. +// +// ---- Vad som mäts ------------------------------------------------------- +// +// Stängning Varje ärende ska nå avslutat läge. Ett fall som fastnar +// är antingen ett hinder utan väg ut eller ett glapp mellan +// klient och grind. +// +// Schemat Varje händelse klienten FAKTISKT producerar valideras mot +// det stängda schemat. En fixtur bevisar ingenting här — +// schemat stängdes utifrån vad koden borde skicka, och det +// enda som räknas är vad den skickar. +// +// Budget Interaktioner per ärende. Ett verktyg som tyst växer från +// 65 klick till 90 överges i verkstaden långt innan någon +// skriver en felrapport. Taket är därför ett testvillkor, +// inte en anteckning. +// +// Körs med `npm run genomgang` (bygger, serverar och kör). + +import { chromium } from "playwright"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; +import { granskaHändelse } from "../../services/gemensam/handelser.mjs"; + +const ROT = fileURLToPath(new URL("..", import.meta.url)); +const DIST = join(ROT, "dist"); +const PORT = Number(process.env.GENOMGANG_PORT ?? 4188); +const BAS = `http://127.0.0.1:${PORT}`; + +/** Taket per ärende. Höjs bara medvetet, och då syns det i en diff. */ +const BUDGET = 90; + +const PIXEL = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); + +// Fallen är valda för att träffa olika metodiker och olika sorters +// evidens: foto, mätvärde, kommentar, säkerhetsspärr. +const FALL = [ + { + id: "vibration", + regnr: "TST101", + objekt: "Volvo XC60 D4 2019", + fel: "Vibration i ratten vid 90 km/h, blir värre över 110.", + matvarde: "38 g obalans höger framhjul, övriga inom tolerans.", + avvikelse: "Höger framhjul har 38 g obalans mot tillverkarens gräns på 10 g, vilket ger rattvibration i intervallet.", + atgard: "Balansera om höger framhjul och genomför ny provkörning i 80–110 km/h.", + }, + { + id: "start_laddning", + regnr: "TST102", + objekt: "VW Passat TDI 2017", + fel: "Bilen startar inte, bara klick vid start på morgonen.", + matvarde: "Vilospänning 11,4 V mot referens 12,6 V efter tolv timmars vila.", + avvikelse: "Batteriets vilospänning ligger på 11,4 V mot referens 12,6 V och faller till 8,1 V under startförsök.", + atgard: "Byt startbatteri och mät laddspänningen efter bytet.", + }, + { + id: "hogvolt", + regnr: "TST103", + objekt: "Nissan Leaf 2020", + fel: "Elbilen tappar räckvidd och laddar långsamt i laddbox.", + matvarde: "Cellspänningsspridning 0,21 V mot tillåtna 0,05 V.", + avvikelse: "Traktionsbatteriets cellspänningsspridning är 0,21 V mot tillåtna 0,05 V, vilket begränsar räckvidd och laddeffekt.", + atgard: "Byt den avvikande modulen och genomför balansering av batteripaketet.", + }, + { + id: "missljud", + regnr: "TST104", + objekt: "Skoda Octavia 2019", + fel: "Tjutande missljud från höger fram vid låg fart.", + matvarde: "Ljudnivå 74 dB vid höger framnav mot 61 dB på vänster sida.", + avvikelse: "Höger framnav ger 74 dB mot 61 dB på vänster sida vid samma hastighet, och ljudet ändras med hjullast.", + atgard: "Byt höger främre hjullager och provkör på samma vägsträcka.", + }, +]; + +const SLUTSATS = (f) => ({ + motivering: + `Mätvärdet ${f.matvarde} ligger utanför referensen, och avvikelsen uppträder bara under den belastning ` + + `där kunden märker felet. Det förklarar varför symptomet är last- och hastighetsberoende, och kopplar ` + + `underlaget direkt till den fastställda orsaken.`, + uteslutet: + `Angränsande komponenter mättes inom tolerans och kunde därför uteslutas. Den enklare förklaringen ` + + `prövades först och föll bort mot mätdata.`, + atgardsval: + `Åtgärden valdes framför justering eftersom mätvärdet ligger utanför toleransen även efter rengöring, ` + + `och en justering därför inte återställer funktionen.`, + kvarstaende: "Inget kvarstår. Symptomet reproducerades före åtgärd och är borta vid kvalitetskontrollen.", +}); + +const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json" }; + +/** Statisk server för det byggda gränssnittet. */ +function servera() { + return createServer(async (req, res) => { + const rel = normalize(decodeURIComponent(req.url.split("?")[0])).replace(/^(\.\.[/\\])+/, ""); + const fil = join(DIST, rel === "/" ? "index.html" : rel); + try { + const kropp = await readFile(fil); + res.writeHead(200, { "content-type": MIME[extname(fil)] ?? "application/octet-stream" }); + res.end(kropp); + } catch { + res.writeHead(200, { "content-type": "text/html" }); + res.end(await readFile(join(DIST, "index.html"))); + } + }).listen(PORT); +} + +async function kor(sida, fall) { + let interaktioner = 0; + const bok = () => (interaktioner += 1); + const vanta = (ms) => sida.waitForTimeout(ms); + const btn = (namn, rot = sida) => rot.getByRole("button", { name: namn }); + const panel = (rubrik) => sida.locator("section").filter({ has: sida.locator("h2", { hasText: rubrik }) }); + + const finns = async (l) => { + try { + return (await l.count()) > 0 && (await l.first().isVisible()); + } catch { + return false; + } + }; + const klicka = async (l) => { + await l.first().click({ timeout: 5000 }); + bok(); + await vanta(70); + }; + const fyll = async (l, text) => { + await l.first().fill(text, { timeout: 5000 }); + bok(); + await vanta(40); + }; + const foto = async (l) => { + await l.first().setInputFiles({ name: "b.png", mimeType: "image/png", buffer: PIXEL }); + bok(); + await vanta(240); + }; + + // ---- Starta ärendet --------------------------------------------------- + await sida.goto(`${BAS}/#/felsokning/nytt`, { waitUntil: "networkidle" }); + await vanta(300); + await klicka(btn(/Fyll i manuellt/i)); + const start = sida.locator('input:not([type="file"])'); + await fyll(start.nth(0), fall.regnr); + await fyll(start.nth(1), fall.objekt); + await klicka(btn(/^Fortsätt$/)); + await vanta(250); + await klicka(btn(/Rätt objekt/i)); + await vanta(250); + await fyll(sida.locator("textarea").first(), fall.fel); + await klicka(btn(/Starta arbetslogg/i)); + await vanta(700); + + if (!sida.url().includes("/arende/")) throw new Error(`${fall.id}: kom aldrig in i ärendet`); + + // ---- Guiden och panelerna -------------------------------------------- + let metodik = ""; + let felorsakGjord = false; + let slutsatsfalt = 0; + + for (let varv = 0; varv < 200; varv++) { + const text = await sida.locator("body").innerText(); + if (!metodik) metodik = text.match(/METODIK:\s*([^\n·]+)/i)?.[1]?.trim() ?? ""; + if (text.includes("FELSÖKNINGEN ÄR AVSLUTAD")) break; + + const pre = panel(/Pre-diagnostik/i); + if (await finns(pre)) { + const ja = btn(/Ja — kontrollerad/i, pre); + if (await finns(ja)) { + await klicka(ja); + await klicka(btn(/^Dokumentera$/i, pre)); + continue; + } + if (await finns(btn(/Fotografera instrumentpanelen/i, pre))) { + await foto(pre.locator('input[type="file"]')); + const falt = pre.locator('input:not([type="file"])'); + if (await finns(falt)) { + await fyll(falt, "84 320 km"); + await klicka(btn(/Registrera|Spara/i, pre)); + } + continue; + } + if (await finns(btn(/Stämmer — verifierad/i, pre))) { + await klicka(btn(/Stämmer — verifierad/i, pre)); + continue; + } + if (await finns(btn(/Inga ytterligare observationer/i, pre))) { + await klicka(btn(/Inga ytterligare observationer/i, pre)); + continue; + } + } + + const guide = panel(/^Metodik:/i); + if (await finns(guide)) { + if (await finns(btn(/Ta foto/i, guide))) { + await foto(guide.locator('input[type="file"]')); + continue; + } + const verifiera = btn(/Markera verifierad/i, guide); + if (await finns(verifiera)) { + const falt = guide.locator('input[type="text"], textarea').first(); + if (await finns(falt)) await fyll(falt, fall.matvarde); + if (!(await verifiera.first().isDisabled())) { + await klicka(verifiera); + continue; + } + } + if (await finns(btn(/Spara svar/i, guide))) { + const falt = guide.locator('input[type="text"], textarea').first(); + if (await finns(falt)) await fyll(falt, `Enligt kundens uppgift: ${fall.fel}`); + await klicka(btn(/Spara svar/i, guide)); + continue; + } + // Säkerhetsfrågor besvaras jakande; spärren vid Nej provas i + // enhetstesterna, inte här. + if (await finns(btn(/^Ja$/, guide))) { + await klicka(btn(/^Ja$/, guide)); + continue; + } + const val = guide.locator("button").filter({ hasNot: sida.locator("svg") }); + if ((await val.count()) > 0 && !text.includes("Samtliga steg i metodiken")) { + await klicka(val.first()); + continue; + } + } + + const repro = panel(/reproducering/i); + if (await finns(repro)) { + await klicka(btn(/^Ja$/, repro)); + await fyll(repro.locator("textarea"), `${fall.fel} Återskapat vid provkörning under samma förhållanden.`); + await klicka(btn(/Dokumentera reproducering/i, repro)); + continue; + } + + const fo = panel(/Felorsaksanalys/i); + if (!felorsakGjord && (await finns(fo)) && (await finns(btn(/Dokumentera felorsak/i, fo)))) { + await klicka(btn(/Dokumentera felorsak/i, fo)); + await fyll(fo.locator("textarea").first(), fall.avvikelse); + await klicka(btn(/^Normalt slitage$/, fo)); + await klicka(btn(/^Mätresultat$/, fo)); + await fyll(fo.locator('input[type="text"], textarea').last(), fall.atgard); + await klicka(btn(/Spara felorsak/i, fo)); + // En metodik utan mätkontroller har inga mätresultat att luta sig + // mot; systemet nekar då källan och teknikern byter. + if (await finns(btn(/Spara felorsak/i, fo))) { + await klicka(btn(/^Mätresultat$/, fo)); + await klicka(btn(/^Direkt observation$/, fo)); + await klicka(btn(/Spara felorsak/i, fo)); + } + felorsakGjord = true; + continue; + } + + const at = panel(/Åtgärd och kvalitetskontroll/i); + if (await finns(at)) { + if (await finns(btn(/Lämna åtgärdsförslag till kund/i, at))) { + await klicka(btn(/Lämna åtgärdsförslag till kund/i, at)); + await klicka(btn(/^Lämna förslag$/i, at)); + continue; + } + if (await finns(btn(/^Godkänt$/i, at))) { + await klicka(btn(/^Godkänt$/i, at)); + await klicka(btn(/^Telefon$/i, at)); + await klicka(btn(/Registrera besked/i, at)); + continue; + } + if (await finns(btn(/Dokumentera utförd åtgärd/i, at))) { + await klicka(btn(/Dokumentera utförd åtgärd/i, at)); + await fyll(at.locator("textarea").first(), `${fall.atgard} Utfört enligt den dokumenterade felorsaken.`); + await klicka(btn(/Spara åtgärd/i, at)); + continue; + } + if (await finns(btn(/Symptomet är borta/i, at))) { + await klicka(btn(/Symptomet är borta/i, at)); + await fyll(at.locator("textarea").first(), "Ny provkörning på samma vägsträcka — symptomet uppträder inte längre."); + await klicka(btn(/Spara kvalitetskontroll/i, at)); + continue; + } + } + + const mat = panel(/Utgående mätarställning/i); + if (await finns(mat)) { + await foto(mat.locator('input[type="file"]')); + const falt = mat.locator('input:not([type="file"])'); + if (await finns(falt)) { + await fyll(falt, "84 520 km"); + await klicka(btn(/Registrera|Spara/i, mat)); + } + continue; + } + + if (await finns(sida.locator("#slutsats-motivering"))) { + const synliga = await sida.locator('textarea[id^="slutsats-"]').count(); + if (synliga > slutsatsfalt) { + for (const [id, txt] of Object.entries(SLUTSATS(fall))) { + const f = sida.locator(`#slutsats-${id}`); + if (await finns(f)) await fyll(f, txt); + } + const reg = btn(/Registrera slutsats|Uppdatera slutsats/i); + if ((await finns(reg)) && !(await reg.first().isDisabled())) { + await klicka(reg); + slutsatsfalt = synliga; + continue; + } + } + } + + const avsluta = btn(/^Avsluta felsökning$/i); + if ((await finns(avsluta)) && !(await avsluta.last().isDisabled())) { + await klicka(avsluta.last()); + continue; + } + + break; + } + + const slut = await sida.locator("body").innerText(); + const hinder = (await sida.locator("li").allInnerTexts()).filter((t) => t.length > 12).slice(0, 4); + + // Allt klienten faktiskt producerade, mot det stängda schemat. + const handelser = await sida.evaluate(() => { + const ut = []; + for (let i = 0; i < localStorage.length; i++) { + try { + const v = JSON.parse(localStorage.getItem(localStorage.key(i))); + const arenden = v?.state?.arenden ?? v?.arenden; + if (arenden) for (const a of Object.values(arenden)) ut.push(...(a.handelser ?? [])); + } catch { + /* inte vår nyckel */ + } + } + return ut.map((p) => p.handelse); + }); + + return { + metodik, + avslutat: slut.includes("FELSÖKNINGEN ÄR AVSLUTAD"), + interaktioner, + handelser: handelser.length, + avvisade: handelser.map((h) => [h?.typ, granskaHändelse(h)]).filter(([, fel]) => fel !== null), + hinder, + }; +} + +// ---- Körning ------------------------------------------------------------- +// +// Bygget görs härifrån, med den miljö genomgången kräver, i stället för +// att lämnas åt den som startar kommandot. Två variabler avgör om appen +// över huvud taget renderar: +// +// VITE_HASH_ROUTER Sidan serveras utan historik-routing, så +// utan hash matchar ingen route. +// VITE_SUPABASE_* Den gamla butiksklienten byggs fortfarande +// med och konstrueras vid import. Saknas +// värdena kastar den innan routern monteras +// och hela sidan blir tom — det tog en halv +// felsökning att förstå första gången. +// +// Platshållarna pekar på en adress som inte finns. Det är avsiktligt: +// genomgången ska aldrig nå ett nät. + +if (!process.env.GENOMGANG_HOPPA_BYGG) { + execFileSync("npx", ["vite", "build"], { + cwd: ROT, + stdio: ["ignore", "ignore", "inherit"], + env: { + ...process.env, + VITE_HASH_ROUTER: "1", + VITE_SUPABASE_URL: "https://genomgang.invalid", + VITE_SUPABASE_PUBLISHABLE_KEY: "genomgang", + }, + }); +} + +if (!existsSync(join(DIST, "index.html"))) { + console.error(`Inget bygge i ${DIST}.`); + process.exit(1); +} + +const server = servera(); +const webblasare = await chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM ?? undefined, +}); + +const fel = []; +const rader = []; + +try { + for (const fall of FALL) { + const ctx = await webblasare.newContext({ viewport: { width: 1440, height: 1000 } }); + const sida = await ctx.newPage(); + await sida.goto(`${BAS}/#/felsokning`, { waitUntil: "networkidle" }); + await sida.waitForTimeout(300); + if (await sida.locator("input").count()) { + await sida.locator("input").first().fill("Anna Tekniker"); + await sida.getByRole("button").first().click(); + await sida.waitForTimeout(300); + } + + const r = await kor(sida, fall); + rader.push({ fall: fall.id, ...r }); + await ctx.close(); + + if (!r.avslutat) fel.push(`${fall.id}: nådde aldrig avslutat läge. Kvarvarande hinder: ${r.hinder.join(" | ") || "(inga visade)"}`); + if (r.avvisade.length > 0) fel.push(`${fall.id}: ${r.avvisade.length} händelser avvisas av schemat — ${JSON.stringify(r.avvisade.slice(0, 3))}`); + if (r.interaktioner > BUDGET) fel.push(`${fall.id}: ${r.interaktioner} interaktioner överskrider budgeten ${BUDGET}`); + if (r.handelser === 0) fel.push(`${fall.id}: ingen händelse hamnade i loggen`); + } +} finally { + await webblasare.close(); + server.close(); +} + +const bredd = Math.max(...rader.map((r) => r.fall.length)); +for (const r of rader) { + console.log( + `${r.fall.padEnd(bredd)} ${String(r.interaktioner).padStart(3)} interaktioner ` + + `${String(r.handelser).padStart(3)} händelser ${r.avslutat ? "avslutat" : "ÖPPET"} ${r.metodik}`, + ); +} + +if (fel.length > 0) { + console.error(`\nGenomgången misslyckades (${fel.length}):`); + for (const f of fel) console.error(` · ${f}`); + process.exit(1); +} + +console.log(`\n${rader.length}/${rader.length} ärenden avslutade · budget ${BUDGET} interaktioner · schemat rent`); diff --git a/felsokning/app/package-lock.json b/felsokning/app/package-lock.json index aa2e2b9..5ff7ecd 100644 --- a/felsokning/app/package-lock.json +++ b/felsokning/app/package-lock.json @@ -79,6 +79,7 @@ "globals": "^15.15.0", "jsdom": "^20.0.3", "lovable-tagger": "^1.1.13", + "playwright": "^1.62.1", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", "typescript": "^5.8.3", @@ -6095,6 +6096,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", diff --git a/felsokning/app/package.json b/felsokning/app/package.json index d822022..ac198a6 100644 --- a/felsokning/app/package.json +++ b/felsokning/app/package.json @@ -11,7 +11,8 @@ "preview": "vite preview", "typkontroll": "tsc --noEmit -p tsconfig.app.json", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "genomgang": "node e2e/genomgang.mjs" }, "dependencies": { "@hookform/resolvers": "^3.10.0", @@ -85,6 +86,7 @@ "globals": "^15.15.0", "jsdom": "^20.0.3", "lovable-tagger": "^1.1.13", + "playwright": "^1.62.1", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", "typescript": "^5.8.3", diff --git a/felsokning/docs/QUALITY-AUDIT-2.md b/felsokning/docs/QUALITY-AUDIT-2.md index a96cb59..6afceda 100644 --- a/felsokning/docs/QUALITY-AUDIT-2.md +++ b/felsokning/docs/QUALITY-AUDIT-2.md @@ -55,10 +55,13 @@ detected. | Severity | Count | Meaning | | --- | --- | --- | -| Critical | 1 | Blocks supplier approval. Must be closed before any pilot with customer data. | +| Critical | 2 | Blocks supplier approval. Must be closed before any pilot with customer data. | | Major | 3 | Blocks series deployment. Closable within one release cycle. | | Minor | 3 | Track and schedule. | +C-6 was raised during remediation, not during the review, and is recorded with +the findings below. + Revision 1's open items (processor agreement and DPIA under C-4, retiring the Supabase orchestrator under m-4, manual accessibility review under m-6) remain open and are not restated as new findings. @@ -441,6 +444,59 @@ satisfied by a photograph, a measurement or comment check by a result. Three tests lock the distinction, including the negative case — remove the photos and it still blocks. +### C-6 · The test suite did not gate anything (found during remediation) + +`.github/workflows/ci.yml` · root `package.json` + +Raised after the findings above were closed, while wiring the end-to-end +walkthrough into the pipeline. It is the most consequential finding in this +revision and it is embarrassing in a specific way: it invalidates the evidence +both revisions relied on. + +CI runs `npm test` at the repository root, which delegates to +`npm run test --workspaces`. The workspace list is `apps/mobile`, +`services/api`, `infra`. **`felsokning/app` is not among them.** The pipeline +therefore executed 30 tests from an unrelated service and **none of ALVA's +311**. Neither `lint` nor `typecheck` reached the product either. + +The consequence is not that the tests were failing — they pass. It is that +nothing enforced them. Every guarantee both revisions recorded as *"locked by a +test"* — server-side provenance (C-1), the quality gate (C-2), erasure (C-3), +instrument traceability (M-1), the high-voltage interlock (M-2), the closed +schema (C-5), the client/gate drift guards (M-7) — was locked by a test that no +pipeline ran. They held because one engineer ran them by hand before each +commit. That is a person, not a control. + +A test nobody runs is documentation. Recording it as a closure is the same +error this product refuses to make about integration profiles. + +**Closed.** Two new jobs. `alva` runs the product's lint, type check and full +suite against its own lockfile. `genomgang` installs Chromium and runs the +end-to-end walkthrough. + +### The walkthrough as a checked-in test + +`felsokning/app/e2e/genomgang.mjs` · `npm run genomgang` + +Four cases across four methodologies, each driven from an empty screen to a +closed case, asserting three things: + +| Assertion | Why it is here | +| --- | --- | +| Every case reaches a closed state | A case that stalls is either an obstacle with no way out or a client/gate gap. Both have happened. | +| Every event the client actually produces passes the closed schema | The schema was closed against what the code *ought* to send. Only real traffic proves what it *does* send. | +| Interactions per case stay under 90 | A tool that quietly grows from 65 interactions to 90 is abandoned in the bay long before anyone files a defect report. | + +Both failing paths were verified rather than assumed: lowering the budget to 65 +fails three cases and exits 1, and forcing the schema filter to reject +everything fails all four. An assertion that cannot fail is decoration. + +The script builds the app itself, with the two environment variables the build +requires — hash routing, and placeholder credentials for the legacy store client +that is still constructed at import and otherwise throws before the router +mounts, blanking the page. Leaving that to whoever runs the command is how the +walkthrough would quietly start testing a different application. + ### What remains | From | Item | Why it is still open |