Bonus 21: Watcher-Edge-Case-Tests (7 Tests)
- src/watcher.ts: startInboxWatcher als isolierte Funktion - WatcherDeps mit processNote + onError + debounceMs - ActiveWatcher mit close() - chokidar-Calls entkoppelt vom Ingest-Code - src/cli/ingest.ts: nutzt startInboxWatcher statt eigener chokidar-Logik - tests/watcher.test.ts: 7 Edge-Case-Tests 1. startet und schliesst sauber 2. verarbeitet neue .md-Dateien 3. ignoriert Nicht-.md-Dateien 4. ignoriert Verarbeitet/-Unterordner 5. Debounce: mehrere schnelle Writes -> eine Verarbeitung 6. Fehler in processNote via onError-Callback 7. schliesst sauber ohne haengende Prozesse Verifiziert: - 129/129 Tests gruen (vorher 122, 7 neue fuer Watcher) - Echte chokidar-Integration (nicht gemockt) -> testet die Realitaet
This commit is contained in:
1 parent
bf2e6375b0
commit
af05974e79
3 files changed
+228
-31
No files matched your search
+13
-31
@@ -4,9 +4,9 @@
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, renameSync } from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, renameSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import chokidar from 'chokidar';
|
|
||||||
import { callLLM, loadLLMConfig, type ChatRequest, type LLMConfigFromVault } from '../llm.js';
|
import { callLLM, loadLLMConfig, type ChatRequest, type LLMConfigFromVault } from '../llm.js';
|
||||||
import { preCheckConflict } from '../ingest.js';
|
import { preCheckConflict } from '../ingest.js';
|
||||||
|
import { startInboxWatcher, type ActiveWatcher } from '../watcher.js';
|
||||||
|
|
||||||
interface IngestOptions {
|
interface IngestOptions {
|
||||||
mock?: boolean;
|
mock?: boolean;
|
||||||
@@ -98,8 +98,7 @@ export async function ingestCommand(vaultPath: string, args: string[]): Promise<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lauscht auf neue Dateien in 00_Inbox/ und ingestiert sie automatisch.
|
* Lauscht auf neue Dateien in 00_Inbox/ und ingestiert sie automatisch.
|
||||||
* Nutzt chokidar (robuster als node:fs.watch auf Windows).
|
* Wrapper um startInboxWatcher mit Ctrl+C-Handling.
|
||||||
* Debounce 1s, um mehrfache Events pro Datei zu vermeiden.
|
|
||||||
*/
|
*/
|
||||||
async function watchInbox(
|
async function watchInbox(
|
||||||
vaultPath: string,
|
vaultPath: string,
|
||||||
@@ -110,40 +109,23 @@ async function watchInbox(
|
|||||||
dryRun: boolean,
|
dryRun: boolean,
|
||||||
inboxDir: string,
|
inboxDir: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const watcher = chokidar.watch(inboxDir, {
|
let activeWatcher: ActiveWatcher | null = null;
|
||||||
ignored: /(^|[/\\])(\..*|Verarbeitet|Problemfaelle|.*\.tmp$)/,
|
activeWatcher = await startInboxWatcher(inboxDir, {
|
||||||
persistent: true,
|
processNote: async (notePath: string) => {
|
||||||
ignoreInitial: true,
|
console.log(`[watch] Neue Notiz: ${notePath}`);
|
||||||
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
|
await processNote(vaultPath, notePath, schema, sysIndex, wikiIndex, llmConfig, dryRun);
|
||||||
});
|
},
|
||||||
|
onError: (err: Error, notePath: string) => {
|
||||||
let debounceTimer: NodeJS.Timeout | null = null;
|
console.error(`[watch] FEHLER: ${err.message}`);
|
||||||
const pending = new Set<string>();
|
moveToProblemfaelle(vaultPath, notePath, err.message);
|
||||||
|
},
|
||||||
watcher.on('add', (filePath) => {
|
|
||||||
if (!filePath.endsWith('.md')) return;
|
|
||||||
pending.add(filePath);
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
debounceTimer = setTimeout(async () => {
|
|
||||||
const toProcess = [...pending];
|
|
||||||
pending.clear();
|
|
||||||
for (const notePath of toProcess) {
|
|
||||||
console.log(`[watch] Neue Notiz: ${notePath}`);
|
|
||||||
try {
|
|
||||||
await processNote(vaultPath, notePath, schema, sysIndex, wikiIndex, llmConfig, dryRun);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[watch] FEHLER: ${(err as Error).message}`);
|
|
||||||
moveToProblemfaelle(vaultPath, notePath, (err as Error).message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auf Ctrl+C warten
|
// Auf Ctrl+C warten
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log('\n=== Watch-Mode beendet. ===');
|
console.log('\n=== Watch-Mode beendet. ===');
|
||||||
watcher.close().then(() => resolve());
|
activeWatcher?.close().then(() => resolve());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Watcher-Logik (testbar)
|
||||||
|
// Bonus 21: Watcher in separate Datei, mit Dependency-Injection
|
||||||
|
// fuer processNote (testbar ohne chokidar)
|
||||||
|
|
||||||
|
import chokidar from 'chokidar';
|
||||||
|
|
||||||
|
export interface WatcherDeps {
|
||||||
|
processNote: (notePath: string) => Promise<void>;
|
||||||
|
onError?: (err: Error, notePath: string) => void;
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveWatcher {
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet einen Watcher auf `inboxDir` und ruft `processNote` fuer jede
|
||||||
|
* neue .md-Datei (mit Debounce).
|
||||||
|
*
|
||||||
|
* Testbar: processNote wird als Dependency uebergeben.
|
||||||
|
*/
|
||||||
|
export async function startInboxWatcher(inboxDir: string, deps: WatcherDeps): Promise<ActiveWatcher> {
|
||||||
|
const debounceMs = deps.debounceMs ?? 1000;
|
||||||
|
const onError = deps.onError ?? ((err) => console.error(`[watch] FEHLER: ${err.message}`));
|
||||||
|
|
||||||
|
const watcher = chokidar.watch(inboxDir, {
|
||||||
|
ignored: /(^|[/\\])(\..*|Verarbeitet|Problemfaelle|.*\.tmp$)/,
|
||||||
|
persistent: true,
|
||||||
|
ignoreInitial: true,
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const pending = new Set<string>();
|
||||||
|
|
||||||
|
watcher.on('add', (filePath) => {
|
||||||
|
if (!filePath.endsWith('.md')) return;
|
||||||
|
pending.add(filePath);
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
const toProcess = [...pending];
|
||||||
|
pending.clear();
|
||||||
|
for (const notePath of toProcess) {
|
||||||
|
try {
|
||||||
|
await deps.processNote(notePath);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err as Error, notePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, debounceMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
await watcher.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Tests fuer startInboxWatcher
|
||||||
|
// Bonus 21: Watcher-Edge-Cases
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { startInboxWatcher, type ActiveWatcher } from '../src/watcher.js';
|
||||||
|
|
||||||
|
const watchers: ActiveWatcher[] = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const w of watchers) await w.close();
|
||||||
|
watchers.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupInbox(): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'mindomat-watcher-test-'));
|
||||||
|
// Inbox-Struktur
|
||||||
|
for (const d of ['00_Inbox', '00_Inbox/Verarbeitet', '00_Inbox/Problemfaelle']) {
|
||||||
|
mkdirSync(join(dir, d), { recursive: true });
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('startInboxWatcher', () => {
|
||||||
|
it('startet und schliesst sauber', async () => {
|
||||||
|
const inbox = setupInbox();
|
||||||
|
const processed: string[] = [];
|
||||||
|
const watcher = await startInboxWatcher(inbox, {
|
||||||
|
processNote: async (p) => { processed.push(p); },
|
||||||
|
debounceMs: 50,
|
||||||
|
});
|
||||||
|
watchers.push(watcher);
|
||||||
|
expect(watcher).toBeDefined();
|
||||||
|
expect(typeof watcher.close).toBe('function');
|
||||||
|
await watcher.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verarbeitet neue .md-Dateien', async () => {
|
||||||
|
const inbox = setupInbox();
|
||||||
|
const processed: string[] = [];
|
||||||
|
const watcher = await startInboxWatcher(inbox, {
|
||||||
|
processNote: async (p) => { processed.push(p); },
|
||||||
|
debounceMs: 100,
|
||||||
|
});
|
||||||
|
watchers.push(watcher);
|
||||||
|
|
||||||
|
// Warte, bis Watcher bereit ist
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
// Schreibe neue .md-Datei
|
||||||
|
const notePath = join(inbox, '00_Inbox', 'test.md');
|
||||||
|
writeFileSync(notePath, '# Test');
|
||||||
|
|
||||||
|
// Warte auf Debounce + Verarbeitung
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
|
||||||
|
expect(processed.length).toBe(1);
|
||||||
|
expect(processed[0]).toContain('test.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert Nicht-.md-Dateien', async () => {
|
||||||
|
const inbox = setupInbox();
|
||||||
|
const processed: string[] = [];
|
||||||
|
const watcher = await startInboxWatcher(inbox, {
|
||||||
|
processNote: async (p) => { processed.push(p); },
|
||||||
|
debounceMs: 100,
|
||||||
|
});
|
||||||
|
watchers.push(watcher);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
// Schreibe .txt-Datei (sollte ignoriert werden)
|
||||||
|
writeFileSync(join(inbox, '00_Inbox', 'test.txt'), 'not markdown');
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
|
||||||
|
expect(processed.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert Verarbeitet/-Unterordner', async () => {
|
||||||
|
const inbox = setupInbox();
|
||||||
|
const processed: string[] = [];
|
||||||
|
const watcher = await startInboxWatcher(inbox, {
|
||||||
|
processNote: async (p) => { processed.push(p); },
|
||||||
|
debounceMs: 100,
|
||||||
|
});
|
||||||
|
watchers.push(watcher);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
// In Verarbeitet/ schreiben (sollte ignoriert werden)
|
||||||
|
writeFileSync(join(inbox, '00_Inbox', 'Verarbeitet', 'old.md'), '# alt');
|
||||||
|
await new Promise((r) => setTimeout(r, 800));
|
||||||
|
|
||||||
|
expect(processed.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Debounce: mehrere schnelle Writes -> eine Verarbeitung', async () => {
|
||||||
|
const inbox = setupInbox();
|
||||||
|
const processed: string[] = [];
|
||||||
|
const watcher = await startInboxWatcher(inbox, {
|
||||||
|
processNote: async (p) => { processed.push(p); },
|
||||||
|
debounceMs: 500,
|
||||||
|
});
|
||||||
|
watchers.push(watcher);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
// 3 schnelle Writes
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
writeFileSync(join(inbox, '00_Inbox', `note-${i}.md`), `# Note ${i}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
// Warte auf Debounce
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
|
||||||
|
// Alle 3 Notizen sollten verarbeitet sein
|
||||||
|
expect(processed.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(processed.length).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler in processNote werden via onError-Callback gemeldet', async () => {
|
||||||
|
const inbox = setupInbox();
|
||||||
|
const errors: Array<{ err: Error; path: string }> = [];
|
||||||
|
const watcher = await startInboxWatcher(inbox, {
|
||||||
|
processNote: async () => { throw new Error('Test-Fehler'); },
|
||||||
|
onError: (err, path) => { errors.push({ err, path }); },
|
||||||
|
debounceMs: 100,
|
||||||
|
});
|
||||||
|
watchers.push(watcher);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
|
||||||
|
writeFileSync(join(inbox, '00_Inbox', 'fail.md'), '# fail');
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
|
||||||
|
expect(errors.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(errors[0].err.message).toBe('Test-Fehler');
|
||||||
|
expect(errors[0].path).toContain('fail.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schliesst sauber ohne haengende Prozesse', async () => {
|
||||||
|
const inbox = setupInbox();
|
||||||
|
const watcher = await startInboxWatcher(inbox, {
|
||||||
|
processNote: async () => {},
|
||||||
|
debounceMs: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vor close: Watcher aktiv
|
||||||
|
expect(watcher).toBeDefined();
|
||||||
|
|
||||||
|
// Close sollte schnell gehen und keine Errors werfen
|
||||||
|
await expect(watcher.close()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user