import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { coverageConfigDefaults } from 'vitest/config'; import konfiguration from '../../vitest.config'; /* * Die Wache ueber die Abdeckungsmessung. * * Bis 5.13.0 stand in den Unterlagen eine Abdeckungszahl, die kein Test hielt: Sie * entstand nur in einem eigenen Lauf, und gemessen wurden allein * `src/domain/**` und `src/services/**`. Oberflaeche, Ausdruck und * Hauptprozess waren getestet, aber ungemessen; Schwellen gab es keine. Wer * Pruefung abbaute, merkte es nicht, und wer die Zahl im Text aenderte, musste * nichts nachrechnen. * * `vitest.config.ts` misst jetzt `src/**`, `shared/**`, `electron/**` und * `tools/**` und traegt je Bereich eine Schwelle knapp unter dem gemessenen * Stand. Damit faellt der Lauf, wenn die Abdeckung sinkt. * * WAS DIESE DATEI DAZU BEITRAEGT - und was sie ausdruecklich NICHT tut. Sie * faehrt KEINE Messung. Eine Abdeckung entsteht nur unter `--coverage`; ein * Testfall, der das noch einmal anwuerfe, verdoppelte die teuerste Stufe des * Laufs (gemessen: 34,6 s ohne, 53,2 s mit Messung). Bewacht wird stattdessen * alles, was ohne Messung nachpruefbar ist und ohne das die Schwellen nichts * wert waeren: * * 1. dass der gemessene UMFANG jede Quelldatei des Baumes erfasst - eine * Datei oder ein Verzeichnis, das niemand in `include` nachtraegt, waere * sonst stumm ungemessen und sein Fehlen an keiner Zahl zu sehen; * 2. dass die einzige AUSNAHME (`electron/preload.ts`) namentlich dasteht, * die Vorgaben von Vitest daneben stehen bleiben und die beiden Wachen * noch existieren, auf die sich die Ausnahme beruft; * 3. dass jeder gemessene Bereich eine EIGENE Schwelle hat - eine Schwelle * fuer alles liesse `src/domain/**` die Oberflaeche zudecken; * 4. dass keine Schwelle unter den Stand GESENKT wurde, auf den sie gesetzt * worden ist. Anheben ist frei, Senken verlangt, hier dieselbe Zahl * nachzuziehen - also eine bewusste Entscheidung statt eines Handgriffs; * 5. dass die Schwellen ueberhaupt GEFAHREN werden. Eine Schwelle, die * niemand faehrt, bewacht nichts; sie haengt an der Kette * `.githooks/pre-commit` -> `gate:schnell` -> `test:coverage`. * * NACHGEBESSERT: Die Punkte 1 und 3 sahen zuerst nur auf die * WURZELVERZEICHNISSE: Aus dem Muster `src/**` wurde `src`, und damit war ein * `include` von `src/domain/**` von einem `include` von `src/**` nicht zu * unterscheiden. Wer den Umfang INNERHALB von `src/` zusammenstrich, nahm * Oberflaeche, Ausdruck, Speicherort und Bruecke aus der Messung, ohne dass * etwas rot wurde - auch der Abdeckungslauf nicht: Ein Schwellenmuster ohne * gemessene Datei ergibt eine leere Abdeckungskarte, und die zaehlt istanbul * als 100 %. Beide Faelle halten jetzt jede einzelne Quelldatei des Baumes * gegen die vollstaendigen Muster aus `coverage.include`, Pfad fuer Pfad statt * Wurzel fuer Wurzel. * * Nicht bewacht - und das bleibt so - ist die Abdeckungszahl selbst. Sie * entsteht im Lauf mit `--coverage`, und dort halten sie die Schwellen. */ /* ------------------------------------------------------------------------- * Die Einstellungen, wie sie in vitest.config.ts stehen * ---------------------------------------------------------------------- */ /** Die vier Schwellenarten, die Vitest kennt. */ interface Schwellensatz { readonly lines?: number; readonly branches?: number; readonly functions?: number; readonly statements?: number; } /** * Der Abdeckungsteil der Einstellungen. * * Eingebunden wird die Einstellungsdatei selbst und nicht ihr Wortlaut: So * bricht eine Umbenennung beim Uebersetzen und nicht erst hier, und die Werte * kommen aus derselben Quelle, die Vitest liest. */ interface Abdeckungsteil extends Schwellensatz { readonly include?: readonly string[]; readonly exclude?: readonly string[]; readonly thresholds?: Readonly>; } const ABDECKUNG = konfiguration.test?.coverage as Abdeckungsteil | undefined; const SCHWELLEN = ABDECKUNG?.thresholds ?? {}; /** Die Schluessel, die keine Glob-Muster sind, sondern Einstellungen. */ const KEINE_GLOBS = new Set([ 'lines', 'branches', 'functions', 'statements', 'perFile', 'autoUpdate', '100', ]); /** Alle Glob-Muster der Schwellentabelle, in der Reihenfolge der Datei. */ const GLOBS = Object.keys(SCHWELLEN).filter((s) => !KEINE_GLOBS.has(s)); /** Der Schwellensatz zu einem Glob-Muster. */ function satz(glob: string): Schwellensatz { const wert = SCHWELLEN[glob]; expect(typeof wert, `Schwellensatz zu "${glob}"`).toBe('object'); return wert as Schwellensatz; } /* ------------------------------------------------------------------------- * 1. Der Umfang * ---------------------------------------------------------------------- */ /** * Verzeichnisse der Wurzel, die absichtlich NICHT gemessen werden - jedes mit * dem Grund, warum eine Zahl darueber nichts aussagte. Kommt ein Verzeichnis * mit Quelltext hinzu, das weder hier noch in `include` steht, faellt der Fall * darunter. */ const AUSSERHALB: Readonly> = { tests: 'die Pruefung selbst', e2e: 'der Rauchtest, laeuft unter Playwright und nicht unter Vitest', scripts: 'laeuft als eigener Prozess (spawnSync in tests/tools), die v8-Messung dieses Prozesses sieht davon nichts', node_modules: 'fremder Kode', build: 'Bauergebnis', release: 'Bauergebnis', coverage: 'Ergebnis der Messung selbst', 'test-results': 'Ausgabe des Rauchtests', assets: 'Bilddateien', beispiele: 'Beispieldaten', docs: 'Unterlagen', public: 'unveraendert mitkopierte Dateien, nichts wird daraus uebersetzt', '.git': 'Versionsverwaltung', '.githooks': 'Haken der Versionsverwaltung, Shell', '.vscode': 'oertliche Einstellungen des Arbeitsplatzes', }; /** * Einzelne Dateien der Wurzel, die absichtlich nicht gemessen werden - wie * AUSSERHALB, nur je Datei. Es sind die Einstellungen der Werkzeuge; zwei davon * nimmt Vitest schon ueber seine Vorgabeliste heraus, `playwright.config.ts` * steht dort nicht. Eine weitere Datei in der Wurzel gehoert hierher, mit Grund, * und wird nicht stillschweigend uebergangen. */ const AUSSERHALB_DATEIEN: Readonly> = { 'vite.config.ts': 'Einstellungen des Bauwerkzeugs, kein Bestandteil der Fassung', 'vitest.config.ts': 'Einstellungen des Testlaufs, kein Bestandteil der Fassung', 'playwright.config.ts': 'Einstellungen des Rauchtests, kein Bestandteil der Fassung', }; /** * Passt der Pfad auf das Glob-Muster? * * Dieselbe Frage, die Vitest stellt: Die Schwellenmuster vergleicht es mit * `micromatch.isMatch(relative(root, datei), glob)`, den Umfang mit * `test-exclude` (minimatch). Beide Pakete liegen nur mittelbar im Baum, und * aufgenommen wird keines. Nachgebildet ist deshalb genau der Ausschnitt, den * `coverage.include` und die Schwellentabelle brauchen: `**` ueber beliebig * viele Abschnitte, `*` und `?` innerhalb eines Abschnitts, sonst feste * Zeichen. Verglichen wird mit Schraegstrich, so wie beide Werkzeuge es tun. * * Jedes andere Sonderzeichen der Glob-Sprache bricht ab, statt einfach nicht zu * passen: Ein Muster, das diese Wache nicht lesen kann, darf sie nicht fuer * erfuellt erklaeren. */ function passt(pfad: string, muster: string): boolean { if (/[{}()[\]!+@|]/.test(muster)) { throw new Error( `Das Muster "${muster}" nutzt Glob-Zeichen, die diese Wache nicht nachbildet - ` + 'entweder das Muster einfach halten oder den Vergleich hier erweitern.', ); } if (muster.split('/').some((teil) => teil.includes('**') && teil !== '**')) { // Bei micromatch steht ein "**", das nicht allein im Abschnitt steht, nur // fuer "*". Diese Wache bildet das nicht nach und sagt es lieber. throw new Error( `Das Muster "${muster}" mischt "**" mit anderen Zeichen im selben Abschnitt - ` + 'so gelesen waere es weiter als bei Vitest.', ); } let quelle = '^'; for (let i = 0; i < muster.length; i += 1) { const zeichen = muster[i]; if (zeichen === '*' && muster[i + 1] === '*') { i += 1; // "**/" darf auch fuer keinen Abschnitt stehen, "**" am Ende fuer alles. if (muster[i + 1] === '/') { i += 1; quelle += '(?:[^/]+/)*'; } else { quelle += '.*'; } } else if (zeichen === '*') { quelle += '[^/]*'; } else if (zeichen === '?') { quelle += '[^/]'; } else { quelle += zeichen === undefined ? '' : zeichen.replace(/[.^$\\]/g, '\\$&'); } } return new RegExp(`${quelle}$`).test(pfad); } /** Die Ausschlussmuster, die Vitest von sich aus mitbringt. */ const VORGABE_AUS = coverageConfigDefaults.exclude; /** Die Ausschlussmuster, die diese Fassung selbst gesetzt hat. */ const EIGENE_AUS = (ABDECKUNG?.exclude ?? []).filter((m) => !VORGABE_AUS.includes(m)); /** * Wird diese Datei gemessen? * * Gefragt wird nach `coverage.include` und den eigenen Ausnahmen. Die * Vorgabeliste von Vitest bleibt aussen vor - was sie trifft (Typmodule, * Testdateien, die bekannten Einstellungsdateien), liegt im Baum entweder unter * AUSSERHALB oder in AUSSERHALB_DATEIEN, und dass sie vollstaendig stehen * bleibt, prueft ein eigener Fall. */ function wirdGemessen(datei: string): boolean { return ( (ABDECKUNG?.include ?? []).some((m) => passt(datei, m)) && !EIGENE_AUS.some((m) => passt(datei, m)) ); } /** * Nennt die .gitignore das Verzeichnis der Wurzel? Dann gehoert es nicht zum * Baum, und die Suche geht nicht hinein - auch nicht in ein Verzeichnis, das an * einem Arbeitsplatz daneben liegt und in diesem Quelltext nirgends vorkommt. * Gefragt wird Git selbst (`--no-index`: allein nach den Mustern); fehlt Git, * gilt das Verzeichnis als Teil des Baumes und wird durchsucht. `wurzel` ist * das Verzeichnis, in dem Git fragt - die Wurzel des durchsuchten Baumes. */ function gitIgnoriert(verzeichnis: string, wurzel = '.'): boolean { return ( spawnSync('git', ['check-ignore', '-q', '--no-index', `${verzeichnis}/`], { cwd: wurzel }) .status === 0 ); } /** * Alle Quelldateien des Baumes, ohne die Verzeichnisse aus AUSSERHALB und ohne * die, die die .gitignore nennt - als Pfade von der Wurzel aus, mit * Schraegstrich. Typmodule (`*.d.ts`) tragen keinen ausfuehrbaren Kode und * bleiben aussen vor; Vitest schliesst sie ohnehin aus, wie auch alles, was mit * einem Punkt beginnt (`**\/[.]**`). */ function quelldateien(verzeichnis: string, praefix = ''): string[] { const gefunden: string[] = []; for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) { if (eintrag.name.startsWith('.')) continue; const relativ = praefix === '' ? eintrag.name : `${praefix}/${eintrag.name}`; if (eintrag.isDirectory()) { if ( praefix === '' && (eintrag.name in AUSSERHALB || gitIgnoriert(eintrag.name, verzeichnis)) ) { continue; } gefunden.push(...quelldateien(path.join(verzeichnis, eintrag.name), relativ)); } else if (eintrag.name.endsWith('.ts') && !eintrag.name.endsWith('.d.ts')) { gefunden.push(relativ); } } return gefunden; } /** Enthaelt das Verzeichnis - auch tiefer - eine .ts-Datei, die kein Typmodul ist? */ function traegtQuelltext(verzeichnis: string): boolean { for (const eintrag of readdirSync(verzeichnis, { withFileTypes: true })) { const pfad = path.join(verzeichnis, eintrag.name); if (eintrag.isDirectory()) { if (traegtQuelltext(pfad)) return true; } else if (eintrag.name.endsWith('.ts') && !eintrag.name.endsWith('.d.ts')) { return true; } } return false; } describe('Abdeckungsmessung: der Umfang', () => { it('vergleicht Pfad und Muster so, wie es die Muster verlangen', () => { // Der Vergleich oben ist selbstgebaut; hier steht, was er zusagt. Ohne // diese Faelle stuende die ganze Umfangswache auf einer ungeprueften // Zeichenkettenrechnung. expect(passt('src/main.ts', 'src/**'), 'src/** trifft die Datei daneben').toBe(true); expect(passt('src/ui/views/projectView.ts', 'src/**'), 'src/** trifft auch tiefer').toBe(true); expect(passt('src', 'src/**'), 'src/** trifft das Verzeichnis selbst nicht').toBe(false); expect(passt('src/ui/dom.ts', 'src/domain/**'), 'src/domain/** trifft src/ui nicht').toBe( false, ); expect(passt('src/main.ts', 'src/main.ts'), 'ein Muster ohne Sternchen').toBe(true); expect(passt('src/mainXts', 'src/main.ts'), 'der Punkt ist kein Platzhalter').toBe(false); expect(passt('a/b.ts', '**/*.ts'), '**/ darf ueber einen Abschnitt gehen').toBe(true); expect(passt('b.ts', '**/*.ts'), '**/ darf auch ueber keinen gehen').toBe(true); expect(() => passt('a.ts', 'a.{ts,js}'), 'unbekanntes Glob-Zeichen').toThrow(); expect(() => passt('a.ts', 'src/**.ts'), '"**" mitten im Abschnitt').toThrow(); }); it('misst jede Quelldatei des Baumes, die nicht namentlich ausgenommen ist', () => { /* * Datei fuer Datei und nicht Wurzelverzeichnis fuer Wurzelverzeichnis: Ein * `include` von `src/domain/**` liegt unter demselben Wurzelverzeichnis wie * `src/**` und waere so nicht davon zu unterscheiden - dabei naehme es * `src/ui`, `src/app`, `src/render`, `src/services` und `src/platform` aus * der Messung, ohne dass eine Zahl faellt. * * Unerfasst bleiben darf nur, was in AUSSERHALB, in AUSSERHALB_DATEIEN oder * in den eigenen Ausnahmen der Einstellungen steht - also die eine Datei * `electron/preload.ts`, an den beiden Faellen weiter unten festgemacht. */ const muster = ABDECKUNG?.include ?? []; expect(muster.length, 'coverage.include ist leer').toBeGreaterThan(0); const ungemessen = quelldateien('.').filter( (datei) => !(datei in AUSSERHALB_DATEIEN) && !EIGENE_AUS.some((m) => passt(datei, m)) && !muster.some((m) => passt(datei, m)), ); expect( ungemessen, 'diese Dateien tragen Quelltext, werden aber weder gemessen noch als Ausnahme gefuehrt', ).toEqual([]); }); it('geht in kein Verzeichnis, das die .gitignore nennt', () => { /* * An einem Wegwerfbaum und nicht an diesem: Hier traegt kein Verzeichnis, * das die .gitignore nennt, eine .ts-Datei, und ohne die Frage an die * .gitignore blieben die Faelle darueber gruen. Im Wegwerfbaum liegt je * eine Quelldatei in einem genannten Verzeichnis und in einem, das niemand * nennt; gefunden werden darf nur die zweite. */ const wurzel = mkdtempSync(path.join(os.tmpdir(), 'lsa-abdeckung-')); try { const anlegen = spawnSync('git', ['init', '-q', wurzel], { encoding: 'utf8' }); expect(anlegen.status, `git init: ${anlegen.stderr}`).toBe(0); writeFileSync(path.join(wurzel, '.gitignore'), 'ablage/\n', 'utf8'); for (const verzeichnis of ['ablage', 'quelle']) { mkdirSync(path.join(wurzel, verzeichnis)); writeFileSync(path.join(wurzel, verzeichnis, 'modul.ts'), 'export const x = 1;\n', 'utf8'); } expect(quelldateien(wurzel), 'die Suche ging in ein genanntes Verzeichnis').toEqual([ 'quelle/modul.ts', ]); } finally { rmSync(wurzel, { recursive: true, force: true }); } }); it('bleibt jede Ausnahme, die hier steht, auch wirklich ausserhalb', () => { // Die Gegenrichtung: Ein Eintrag in AUSSERHALB, den `include` doch // mitnimmt, taeuschte den naechsten Leser ueber den Umfang. Gefragt wird // mit einem gedachten Pfad je Verzeichnis - griffe `include` darauf zu, // gehoerte das Verzeichnis nicht in diese Tabelle. const doppelt = Object.keys(AUSSERHALB).filter((name) => (ABDECKUNG?.include ?? []).some((m) => passt(`${name}/beispiel.ts`, m)), ); expect(doppelt, 'als Ausnahme gefuehrt und trotzdem von coverage.include erfasst').toEqual([]); }); it('nennt genau eine eigene Ausnahme und laesst die Vorgaben von Vitest stehen', () => { /* * `coverage.exclude` ERSETZT die Vorgabeliste, sie ergaenzt sie nicht. Wer * hier den Spreizoperator vergisst, nimmt unbemerkt `**\/*.d.ts` und die * Testmuster wieder in die Messung auf. */ const ausschluss = ABDECKUNG?.exclude ?? []; for (const vorgabe of coverageConfigDefaults.exclude) { expect(ausschluss, `Vorgabe "${vorgabe}" fehlt in coverage.exclude`).toContain(vorgabe); } const eigene = ausschluss.filter((m) => !coverageConfigDefaults.exclude.includes(m)); expect(eigene, 'eigene Ausnahmen der Messung').toEqual(['electron/preload.ts']); }); it('laesst die Ausnahme nur gelten, solange preload.ts anderswo geprueft wird', () => { /* * Ohne diesen Fall waere die Ausnahme oben eine Beschoenigung: Man nimmt * die schlecht gemessene Datei heraus, und die Zahl steigt. Sie ist nur * zulaessig, weil zwei andere Wachen die Datei halten - eine am Quelltext, * eine am gebauten Stand. Faellt eine davon weg, gehoert preload.ts * zurueck in die Messung oder braucht einen neuen Ersatz. */ const bruecke = readFileSync('tests/electron/brueckeVersion.test.ts', 'utf8'); expect(bruecke, 'brueckeVersion.test.ts liest preload.ts nicht mehr').toContain('preload.ts'); const rauchtest = readFileSync('e2e/rauchtest.spec.ts', 'utf8'); expect(rauchtest, 'der Rauchtest nennt preload.ts nicht mehr').toContain('preload.ts'); expect(rauchtest, 'der Rauchtest prueft die Bruecke nicht mehr').toContain( 'die Bruecke ist vollstaendig freigegeben', ); expect(rauchtest, 'der Rauchtest liest window.lsaPlaner nicht mehr').toContain('lsaPlaner'); }); }); /* ------------------------------------------------------------------------- * 2. Die Schwellen * ---------------------------------------------------------------------- */ /** * Der Stand, auf den die Schwellen gesetzt worden sind (Fassung 5.13.0, * gemessen mit `npm run test:coverage`). * * DIESE TABELLE IST DIE EIGENTLICHE WACHE. Eine Schwelle in `vitest.config.ts` * faellt nur, wenn jemand mit `--coverage` faehrt; eine gesenkte Schwelle * faellt dort NIE auf - sie macht den Lauf ja gerade wieder gruen. Hier steht * dieselbe Zahl ein zweites Mal, und dieser Fall haelt beide gegeneinander: * Anheben ist frei, Senken verlangt denselben Handgriff auch hier. * * Wer eine Zahl hier senkt, senkt die Pruefdichte des Erzeugnisses. Das ist * kein Formfehler, sondern eine fachliche Entscheidung, und sie gehoert in die * Commit-Botschaft. */ const UNTERGRENZEN: Readonly> = { global: { lines: 93.4, branches: 89.0, functions: 90.4 }, 'src/domain/**': { lines: 96.3, branches: 91.4, functions: 97.1 }, 'src/services/**': { lines: 95.7, branches: 89.8, functions: 97.6 }, 'src/app/**': { lines: 94.4, branches: 91.8, functions: 93.7 }, 'src/render/**': { lines: 99.1, branches: 92.5, functions: 99.9 }, 'src/ui/**': { lines: 91.3, branches: 86.0, functions: 84.1 }, 'src/platform/**': { lines: 66.5, branches: 49.9, functions: 49.9 }, 'src/main.ts': { lines: 81.2, branches: 75.6, functions: 74.9 }, 'shared/**': { lines: 99.9, branches: 99.9, functions: 99.9 }, 'electron/**': { lines: 83.4, branches: 84.6, functions: 79.3 }, // Mit 5.43.0 gesenkt: Die Behebung der Zeichenrichtung nimmt dem Werkzeug // den Hinweis "einfahrweg-null" und damit zwei Ausgabestellen (Begruendung // in vitest.config.ts). 'tools/**': { lines: 98.9, branches: 88.5, functions: 99.9 }, }; describe('Abdeckungsmessung: die Schwellen', () => { it('traegt jeder gemessene Bereich eine eigene Schwelle', () => { /* * Abgeleitet vom Baum, nicht abgeschrieben: Jedes unmittelbare * Unterverzeichnis von `src/`, das Quelltext traegt, braucht einen eigenen * Eintrag - sonst deckt der Durchschnitt der grossen Bereiche einen neuen, * unpruefbaren zu. `src/styles/` traegt nur Stilblaetter und faellt hier * heraus, ohne dass es aufgezaehlt werden muss. */ const fehlend: string[] = []; for (const eintrag of readdirSync('src', { withFileTypes: true })) { if (!eintrag.isDirectory()) continue; if (!traegtQuelltext(path.join('src', eintrag.name))) continue; const glob = `src/${eintrag.name}/**`; if (!GLOBS.includes(glob)) fehlend.push(glob); } for (const wurzel of ['shared', 'electron', 'tools']) { const glob = `${wurzel}/**`; if (!GLOBS.includes(glob)) fehlend.push(glob); } expect(fehlend, 'diese Bereiche werden gemessen, haben aber keine eigene Schwelle').toEqual([]); }); it('zeigt jedes Muster der Schwellentabelle auf etwas, das gemessen wird', () => { /* * Ein Muster, das keine gemessene Datei trifft, ergibt eine leere * Abdeckungskarte, und die zaehlt istanbul als 100 % - die Schwelle waere * stumm immer gruen. Das entsteht auf zwei Wegen: durch einen Tippfehler im * Muster, und durch ein zusammengestrichenes `coverage.include`. Im zweiten * Fall gibt es das Verzeichnis weiterhin, gemessen wird es nicht mehr; * geprueft wird deshalb nicht, ob das Muster im Baum etwas VORFINDET, * sondern ob es etwas GEMESSENES trifft. */ const gemessen = quelldateien('.').filter((datei) => wirdGemessen(datei)); const daneben = GLOBS.filter((glob) => !gemessen.some((datei) => passt(datei, glob))); expect(daneben, 'diese Muster treffen keine gemessene Datei').toEqual([]); }); it('spannt ein Auffangnetz ueber alles, was kein eigenes Muster hat', () => { // Die globalen Werte gelten bei Vitest fuer ALLE gemessenen Dateien, auch // fuer die von einem Muster getroffenen. Sie fangen damit eine neue Datei // unmittelbar unter src/ ab, die noch keinen eigenen Eintrag hat. expect(typeof SCHWELLEN['lines'], 'globale Schwelle fuer Zeilen').toBe('number'); expect(typeof SCHWELLEN['branches'], 'globale Schwelle fuer Zweige').toBe('number'); expect(typeof SCHWELLEN['functions'], 'globale Schwelle fuer Funktionen').toBe('number'); }); it('setzt keine Schwelle fuer Anweisungen neben der fuer Zeilen', () => { /* * Unter dem v8-Anbieter liefern `lines` und `statements` in jedem Bereich * denselben Wert - v8-to-istanbul bildet je Anweisung genau einen * Zeilenbereich. Eine zweite Zahl daneben bewachte dasselbe zweimal und * gaebe vor, zwei Dinge zu messen. */ expect(SCHWELLEN['statements'], 'globale Schwelle fuer Anweisungen').toBeUndefined(); for (const glob of GLOBS) { expect(satz(glob).statements, `Anweisungsschwelle bei "${glob}"`).toBeUndefined(); } }); it('setzt kein autoUpdate', () => { // `autoUpdate` schreibt nach jedem Lauf in vitest.config.ts zurueck. Weil // die Messung im pre-commit-Haken laeuft, veraenderte sie die Wache // waehrend des Commits - und der Baum waere nach dem Lauf ein anderer als // davor. expect(SCHWELLEN['autoUpdate'], 'thresholds.autoUpdate').toBeFalsy(); }); it('fuehrt die Untergrenzen zu genau den Bereichen, die es gibt', () => { // Ohne diesen Fall bewacht der naechste nur die Bereiche, die zufaellig in // beiden Tabellen stehen: Ein neu aufgenommenes Muster ohne Untergrenze // liefe ungeprueft mit. expect(Object.keys(UNTERGRENZEN).sort()).toEqual(['global', ...GLOBS].sort()); }); it('steht keine Schwelle unter dem Stand, auf den sie gesetzt wurde', () => { const gesenkt: string[] = []; for (const [bereich, grenze] of Object.entries(UNTERGRENZEN)) { const ist = bereich === 'global' ? (SCHWELLEN as Schwellensatz) : satz(bereich); for (const art of ['lines', 'branches', 'functions'] as const) { const soll = grenze[art]; if (soll === undefined) continue; const wert = ist[art]; if (wert === undefined) { gesenkt.push(`${bereich}/${art}: fehlt (war ${soll})`); } else if (wert < soll) { gesenkt.push(`${bereich}/${art}: ${wert} statt mindestens ${soll}`); } } } expect( gesenkt, 'gesenkte Schwellen - wenn das beabsichtigt ist, gehoert dieselbe Zahl in UNTERGRENZEN und der Grund in die Commit-Botschaft', ).toEqual([]); }); }); /* ------------------------------------------------------------------------- * 3. Wird die Messung ueberhaupt gefahren? * ---------------------------------------------------------------------- */ interface Paketdatei { readonly scripts: Record; } const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as Paketdatei; const HAKEN = readFileSync('.githooks/pre-commit', 'utf8'); describe('Abdeckungsmessung: wird sie gefahren?', () => { it('faehrt die Schnellstufe die Messung und nicht den blossen Testlauf', () => { /* * Der teure Teil ist der Testlauf, nicht die Messung: `npm run * test:coverage` FAEHRT die Tests, es kommt keiner dazu. Der Zuwachs * betraegt am Arbeitsplatz 18,6 s (34,6 s gegen 53,2 s), die ganze * Schnellstufe damit 81,3 s statt 63,8 s. Dafuer faellt der * Commit, der die Abdeckung senkt, dort, wo er entsteht - und nicht erst * in der Vollstufe vor einer Veroeffentlichung, wenn niemand mehr weiss, * welche Aenderung es war. * * Abgeleitet und nicht abgeschrieben: Gesucht wird der Teilbefehl, den * gate:schnell fuer die Tests anwirft, und dann in dessen Befehlszeile * nach `--coverage`. */ const schnell = PAKET.scripts['gate:schnell']; expect(schnell, 'kein Skript gate:schnell').toBeDefined(); const teilbefehle = [...(schnell ?? '').matchAll(/npm run ([\w:-]+)/g)].map((t) => String(t[1]), ); expect(teilbefehle.length, 'gate:schnell ruft keine Teilbefehle').toBeGreaterThan(0); const mitMessung = teilbefehle.filter((name) => (PAKET.scripts[name] ?? '').includes('--coverage'), ); expect( mitMessung, 'keine Stufe von gate:schnell faehrt vitest mit --coverage - die Schwellen bewachen dann nichts', ).not.toEqual([]); }); it('faehrt der pre-commit-Haken die Schnellstufe', () => { // Das letzte Glied der Kette. Es steht auch in einer Pruefung der // Unterlagen; hier gehoert es dazu, weil ohne dieses Glied die Zusage des // Falls darueber nicht traegt. expect(HAKEN, 'der Haken faehrt gate:schnell nicht').toContain('npm run gate:schnell'); }); it('faengt .gitignore die Ablage der Messung ab', () => { // Die Messung laeuft jetzt bei jedem Commit und legt `coverage/` an. Bliebe // das im git status stehen, wanderte es frueher oder spaeter versehentlich // in einen Commit. const lauf = spawnSync('git', ['check-ignore', '--no-index', '--stdin'], { cwd: process.cwd(), input: 'coverage/index.html\ncoverage/coverage-final.json\n', encoding: 'utf8', }); expect([0, 1], `git check-ignore: ${lauf.stderr}`).toContain(lauf.status); const abgefangen = lauf.stdout .split('\n') .map((z) => z.trim().replace(/\\/g, '/')) .filter((z) => z !== ''); expect(abgefangen.sort(), 'die Ablage der Messung steht im git status').toEqual([ 'coverage/coverage-final.json', 'coverage/index.html', ]); }); });