test: Test-Suite für Single-Server-Konfig, Loop-Logik und Skripte
Node-eigener Test-Runner (keine Dependencies). 35 Tests: - pure-logic: parseVerdict/parseBlockers/normalizeForComparison (real aus der Extension extrahiert), inkl. PASS-WITH-CONCERNS-Abgrenzung - config: models.json/settings.json-Invarianten (kein Port 8002, nur llama-cpp-single/qwen3.5-single), Querkonsistenz - extension-guards: Regression-Schutz fuer jeden Fix (select positional, kein switchModel, before_agent_start-Hook, passed-Logik, Loop-Fallback) - scripts: bash -n + Single-Server-Invarianten Start: ./run-tests.sh Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
56904329d0
commit
ba733fe684
6 changed files with 266 additions and 0 deletions
7
run-tests.sh
Executable file
7
run-tests.sh
Executable file
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Führt die Test-Suite mit Node's eingebautem Test-Runner aus (keine Dependencies nötig).
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "[*] node $(node --version) — führe Tests aus ..."
|
||||||
|
node --test "$SCRIPT_DIR"/tests/*.test.mjs
|
||||||
67
tests/config.test.mjs
Normal file
67
tests/config.test.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Testet die Konfig-Invarianten der Single-Server-Architektur.
|
||||||
|
// Der ursprüngliche Bug war eine Konfig-Inkonsistenz (Default zeigte auf toten Port 8002).
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readJson, readRepoFile } from "./helpers.mjs";
|
||||||
|
|
||||||
|
const models = readJson("models.json");
|
||||||
|
const settings = readJson("settings.json");
|
||||||
|
|
||||||
|
test("models.json: Provider llama-cpp-single hat genau ein Modell qwen3.5-single auf Port 8001", () => {
|
||||||
|
const p = models.providers["llama-cpp-single"];
|
||||||
|
assert.ok(p, "Provider llama-cpp-single fehlt");
|
||||||
|
assert.match(p.baseUrl, /:8001\//, "llama-cpp-single muss auf Port 8001 zeigen");
|
||||||
|
assert.deepEqual(p.models.map((m) => m.id), ["qwen3.5-single"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("models.json: tote Dual-/Alt-Provider sind entfernt", () => {
|
||||||
|
for (const dead of ["llama-cpp", "llama-cpp-coder", "llama-cpp-judge"]) {
|
||||||
|
assert.ok(!(dead in models.providers), `Toter Provider '${dead}' noch vorhanden`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("models.json: kein Verweis mehr auf den toten Port 8002", () => {
|
||||||
|
assert.doesNotMatch(JSON.stringify(models), /:8002/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings.json: Default zeigt auf den Single-Server (nicht Port 8002)", () => {
|
||||||
|
assert.equal(settings.defaultProvider, "llama-cpp-single");
|
||||||
|
assert.equal(settings.defaultModel, "qwen3.5-single");
|
||||||
|
assert.deepEqual(settings.enabledModels, ["llama-cpp-single/qwen3.5-single"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings.json: defaultProvider/defaultModel existieren in models.json", () => {
|
||||||
|
const p = models.providers[settings.defaultProvider];
|
||||||
|
assert.ok(p, `defaultProvider '${settings.defaultProvider}' nicht in models.json`);
|
||||||
|
assert.ok(
|
||||||
|
p.models.some((m) => m.id === settings.defaultModel),
|
||||||
|
`defaultModel '${settings.defaultModel}' nicht im Provider`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings.json: jeder enabledModels-Eintrag verweist auf existierende Provider/Modelle", () => {
|
||||||
|
for (const ref of settings.enabledModels) {
|
||||||
|
const idx = ref.indexOf("/");
|
||||||
|
const prov = ref.slice(0, idx);
|
||||||
|
const id = ref.slice(idx + 1);
|
||||||
|
const p = models.providers[prov];
|
||||||
|
assert.ok(p, `enabledModels: Provider '${prov}' fehlt in models.json`);
|
||||||
|
assert.ok(p.models.some((m) => m.id === id), `enabledModels: Modell '${id}' fehlt in '${prov}'`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings.json ist valides JSON ohne überraschende Felder", () => {
|
||||||
|
// Reine Smoke-Prüfung, dass die Datei parsebar bleibt und die Kernschlüssel da sind.
|
||||||
|
for (const key of ["defaultProvider", "defaultModel", "enabledModels"]) {
|
||||||
|
assert.ok(key in settings, `Schlüssel '${key}' fehlt in settings.json`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Konsistenz: Extension und settings.json nennen denselben Provider/dasselbe Modell", () => {
|
||||||
|
const ext = readRepoFile("pi-coder-judge-extension.ts");
|
||||||
|
assert.match(ext, /SINGLE_PROVIDER = "llama-cpp-single"/);
|
||||||
|
assert.match(ext, /SINGLE_MODEL_ID = "qwen3.5-single"/);
|
||||||
|
assert.equal(settings.defaultProvider, "llama-cpp-single");
|
||||||
|
assert.equal(settings.defaultModel, "qwen3.5-single");
|
||||||
|
});
|
||||||
46
tests/extension-guards.test.mjs
Normal file
46
tests/extension-guards.test.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Statische Regression-Guards: sichern die konkreten Fixes/Umbauten gegen versehentliches
|
||||||
|
// Zurückdrehen ab. Prüft den Extension-Quelltext, ohne ihn auszuführen.
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { extSource } from "./helpers.mjs";
|
||||||
|
|
||||||
|
test("Crash-Fix: ctx.ui.select wird positional aufgerufen, nicht mit einem Objekt", () => {
|
||||||
|
// Der Crash 'reading length' entstand durch ctx.ui.select({...}) statt select(title, options[]).
|
||||||
|
assert.doesNotMatch(extSource, /ui\.select\(\s*\{/, "ui.select darf NICHT mit Objekt aufgerufen werden");
|
||||||
|
assert.match(extSource, /ui\.select\(\s*titleLine\s*,\s*\[/, "ui.select muss (title, options[]) erhalten");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Single-GPU: keine switchModel-Aufrufe mehr (durch selectRole ersetzt)", () => {
|
||||||
|
assert.doesNotMatch(extSource, /switchModel\s*\(\s*pi/);
|
||||||
|
assert.match(extSource, /function selectRole\b/);
|
||||||
|
assert.match(extSource, /function ensureSingleModel\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("System-Prompt-Rollen: before_agent_start-Hook injiziert Coder-/Judge-Persona", () => {
|
||||||
|
assert.match(extSource, /pi\.on\(\s*["']before_agent_start["']/);
|
||||||
|
assert.match(extSource, /function coderPersona\b/);
|
||||||
|
assert.match(extSource, /function judgePersona\b/);
|
||||||
|
// Persona wird angehängt, nicht ersetzt (Pi-Basis-System-Prompt bleibt erhalten).
|
||||||
|
assert.match(extSource, /event\.systemPrompt\s*\+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Quick-Judge abgeschafft: keine quickJudge-Prompt-Funktionen mehr", () => {
|
||||||
|
assert.doesNotMatch(extSource, /function quickJudgePrompt\b/);
|
||||||
|
assert.doesNotMatch(extSource, /function quickJudgeWithTestsPrompt\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fix-Auslösung: PASS WITH CONCERNS gilt nur mit --approve-concerns als bestanden", () => {
|
||||||
|
assert.match(extSource, /passed\s*=\s*verdict === "PASS"/);
|
||||||
|
assert.match(extSource, /verdict === "PASS WITH CONCERNS"\s*&&\s*approveConcerns/);
|
||||||
|
// und der Bestanden-Zweig nutzt 'passed', nicht mehr das alte 'verdict === PASS WITH CONCERNS'.
|
||||||
|
assert.match(extSource, /if\s*\(\s*passed\s*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Loop-Erkennung greift auch ohne Blocker-Abschnitt (Fallback auf Gesamttext)", () => {
|
||||||
|
assert.match(extSource, /parseBlockers\(judgeText\)\s*\|\|\s*judgeText/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("currentRole wird nach jedem optimize-Lauf auf 'coder' zurückgesetzt", () => {
|
||||||
|
assert.match(extSource, /currentRole\s*=\s*"coder"/);
|
||||||
|
});
|
||||||
34
tests/helpers.mjs
Normal file
34
tests/helpers.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Gemeinsame Test-Helfer.
|
||||||
|
//
|
||||||
|
// Wichtig: Die pi-Extension läuft nur im pi-Runtime (importiert @earendil-works/pi-coding-agent
|
||||||
|
// und typebox). Wir importieren sie daher NICHT direkt, sondern extrahieren die reinen,
|
||||||
|
// abhängigkeitsfreien Funktionen zur Testzeit aus der Quelle. So testen wir das Original
|
||||||
|
// (keine divergierende Kopie), ohne die laufende Extension umbauen oder das Runtime laden zu müssen.
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
export const extSource = readFileSync(join(repoRoot, "pi-coder-judge-extension.ts"), "utf8");
|
||||||
|
|
||||||
|
// Extrahiert eine Top-Level-Funktion (Einrückungstiefe 0) per Name aus der Extension-Quelle und
|
||||||
|
// gibt sie als ausführbare Funktion zurück. Nur für reine Funktionen ohne externe Bezüge geeignet.
|
||||||
|
// Die schließende "}" der Funktion steht auf Spalte 0; innere Blöcke sind eingerückt — deshalb
|
||||||
|
// matcht /^}/m zuverlässig das Funktionsende. Einfache TS-Annotationen (: string / : string[])
|
||||||
|
// werden vor der Auswertung entfernt.
|
||||||
|
export function extractFn(name) {
|
||||||
|
const re = new RegExp(`^function ${name}\\b[\\s\\S]*?^}`, "m");
|
||||||
|
const m = extSource.match(re);
|
||||||
|
if (!m) throw new Error(`Funktion '${name}' nicht in der Extension gefunden`);
|
||||||
|
const js = m[0].replace(/:\s*string(\[\])?/g, "");
|
||||||
|
return new Function(`${js}\nreturn ${name};`)();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRepoFile(relPath) {
|
||||||
|
return readFileSync(join(repoRoot, relPath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readJson(relPath) {
|
||||||
|
return JSON.parse(readRepoFile(relPath));
|
||||||
|
}
|
||||||
66
tests/pure-logic.test.mjs
Normal file
66
tests/pure-logic.test.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// Testet die reine Urteils-/Blocker-Parselogik des /optimize-Loops.
|
||||||
|
// Diese Funktionen entscheiden, ob der Coder einen Fix bekommt — Regressionen hier sind kritisch.
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { extractFn } from "./helpers.mjs";
|
||||||
|
|
||||||
|
const parseVerdict = extractFn("parseVerdict");
|
||||||
|
const parseBlockers = extractFn("parseBlockers");
|
||||||
|
const normalizeForComparison = extractFn("normalizeForComparison");
|
||||||
|
|
||||||
|
test("parseVerdict: klares PASS", () => {
|
||||||
|
assert.equal(parseVerdict("Review fertig.\nUrteil: PASS\nKeine Blocker."), "PASS");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseVerdict: FAIL", () => {
|
||||||
|
assert.equal(parseVerdict("Urteil: FAIL"), "FAIL");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseVerdict: PASS WITH CONCERNS wird NICHT als PASS verkürzt (kritisch für Fix-Auslösung)", () => {
|
||||||
|
// Würde die Regex hier 'PASS' zuerst matchen, käme kein Coder-Fix zustande.
|
||||||
|
assert.equal(parseVerdict("Urteil: PASS WITH CONCERNS"), "PASS WITH CONCERNS");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseVerdict: case-insensitiv und mit Bullet-Präfix", () => {
|
||||||
|
assert.equal(parseVerdict("- urteil: pass with concerns"), "PASS WITH CONCERNS");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseVerdict: ohne erkennbares Urteil → UNREADABLE", () => {
|
||||||
|
assert.equal(parseVerdict("Sieht solide aus, keine Anmerkungen."), "UNREADABLE");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseBlockers: extrahiert Bullet-Blocker und stoppt vor Major", () => {
|
||||||
|
const txt = "- Blocker: B1 Argument-Handling unklar\n- Major: M1 chmod fehlt";
|
||||||
|
const blockers = parseBlockers(txt);
|
||||||
|
assert.match(blockers, /B1 Argument-Handling/);
|
||||||
|
assert.doesNotMatch(blockers, /M1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseBlockers: erkennt **Blocker**-Überschrift und stoppt vor Minor", () => {
|
||||||
|
const txt = "**Blocker**\nKaputter Import in main.py\n**Minor**\nTippfehler";
|
||||||
|
const blockers = parseBlockers(txt);
|
||||||
|
assert.match(blockers, /Kaputter Import/);
|
||||||
|
assert.doesNotMatch(blockers, /Tippfehler/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseBlockers: leer, wenn kein Blocker-Abschnitt vorhanden", () => {
|
||||||
|
assert.equal(parseBlockers("Urteil: PASS\nAlles in Ordnung."), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeForComparison: Whitespace-Kollaps, Kleinschreibung, trailing-Satzzeichen", () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeForComparison(" Blocker A. "),
|
||||||
|
normalizeForComparison("blocker a")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeForComparison: gleiche Blocker trotz Formatierungsunterschied → Loop-Erkennung greift", () => {
|
||||||
|
const a = "- Race Condition in waitForIdle!";
|
||||||
|
const b = "Race condition in waitForIdle";
|
||||||
|
assert.notEqual(normalizeForComparison(a), ""); // sanity
|
||||||
|
assert.equal(
|
||||||
|
normalizeForComparison(a.replace(/^- /, "")),
|
||||||
|
normalizeForComparison(b)
|
||||||
|
);
|
||||||
|
});
|
||||||
46
tests/scripts.test.mjs
Normal file
46
tests/scripts.test.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Testet die Shell-Skripte: Syntax + Single-Server-Invarianten.
|
||||||
|
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { repoRoot, readRepoFile } from "./helpers.mjs";
|
||||||
|
|
||||||
|
const shellScripts = readdirSync(repoRoot).filter((f) => f.endsWith(".sh"));
|
||||||
|
|
||||||
|
for (const script of shellScripts) {
|
||||||
|
test(`bash -n: ${script} hat gültige Syntax`, () => {
|
||||||
|
// execFileSync wirft bei Exit != 0 (Syntaxfehler).
|
||||||
|
execFileSync("bash", ["-n", join(repoRoot, script)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Dual-GPU-Skripte sind entfernt", () => {
|
||||||
|
for (const gone of ["start-coder.sh", "start-judge.sh", "start-servers.sh"]) {
|
||||||
|
assert.ok(!shellScripts.includes(gone), `${gone} sollte in der Single-GPU-Welt entfernt sein`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("start-single.sh nutzt eine GPU und registriert Alias qwen3.5-single", () => {
|
||||||
|
const s = readRepoFile("start-single.sh");
|
||||||
|
assert.match(s, /--alias\s+"\$\{MODEL_ALIAS\}"/);
|
||||||
|
assert.match(s, /MODEL_ALIAS="qwen3\.5-single"/);
|
||||||
|
assert.match(s, /device=\$\{GPU_DEVICE\}/, "GPU muss konfigurierbar/einzeln sein");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stop-servers.sh stoppt den Single-Container", () => {
|
||||||
|
assert.match(readRepoFile("stop-servers.sh"), /qwen36-27b-single/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status.sh prüft den Single-Container", () => {
|
||||||
|
assert.match(readRepoFile("status.sh"), /qwen36-27b-single/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("install-Skript deployt settings.json mit vorherigem Backup", () => {
|
||||||
|
const s = readRepoFile("install_servers_and_pi_coder_extension.sh");
|
||||||
|
assert.match(s, /settings\.json\.bak/, "muss settings.json vor dem Überschreiben sichern");
|
||||||
|
assert.match(s, /cp "\$REPO\/settings\.json"/, "muss settings.json aus dem Repo deployen");
|
||||||
|
assert.match(s, /cp "\$REPO\/models\.json"/);
|
||||||
|
assert.match(s, /pi-coder-judge-extension\.ts/);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue