mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
260 lines
9.4 KiB
JavaScript
260 lines
9.4 KiB
JavaScript
const assert = require('assert');
|
|
const esbuild = require('esbuild');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const repoRoot = path.join(__dirname, '..');
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel-reader-tests-'));
|
|
|
|
async function requireBundledModule(relativePath) {
|
|
const entry = path.join(repoRoot, relativePath);
|
|
const outfile = path.join(tempDir, relativePath.replace(/[/.]/g, '_') + '.cjs');
|
|
await esbuild.build({
|
|
entryPoints: [entry],
|
|
bundle: true,
|
|
platform: 'node',
|
|
format: 'cjs',
|
|
outfile,
|
|
plugins: [
|
|
{
|
|
name: 'obsidian-stub',
|
|
setup(build) {
|
|
build.onResolve({ filter: /^obsidian$/ }, () => ({ path: 'obsidian-stub', namespace: 'stub' }));
|
|
build.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({
|
|
contents: 'module.exports = { requestUrl: async () => { throw new Error("requestUrl not available in direct module tests"); } };',
|
|
loader: 'js',
|
|
}));
|
|
},
|
|
},
|
|
],
|
|
});
|
|
return require(outfile);
|
|
}
|
|
|
|
(async () => {
|
|
try {
|
|
const cache = await requireBundledModule('src/cache.ts');
|
|
const cacheManagerModule = await requireBundledModule('src/cache-manager.ts');
|
|
const generation = await requireBundledModule('src/generation.ts');
|
|
const providerParsers = await requireBundledModule('src/provider-parsers.ts');
|
|
const settings = await requireBundledModule('src/settings.ts');
|
|
const streaming = await requireBundledModule('src/streaming.ts');
|
|
|
|
const cacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z' };
|
|
const touched = cache.touchCacheEntry(cacheEntry, '2024-06-01T00:00:00.000Z');
|
|
assert.strictEqual(touched.lastAccessedAt, '2024-06-01T00:00:00.000Z', 'direct cache import touches entries');
|
|
assert.strictEqual(cacheEntry.lastAccessedAt, undefined, 'direct cache import keeps cache entries immutable');
|
|
assert.strictEqual(JSON.parse(cache.serializeCacheFile({ 'a.md': { cards: [] } })).version, 1);
|
|
|
|
function createFakeAdapter() {
|
|
const files = new Map();
|
|
const dirs = new Set();
|
|
return {
|
|
files,
|
|
dirs,
|
|
writes: [],
|
|
exists: async (filePath) => dirs.has(filePath) || files.has(filePath),
|
|
mkdir: async (filePath) => {
|
|
dirs.add(filePath);
|
|
},
|
|
read: async (filePath) => {
|
|
if (!files.has(filePath)) throw new Error('not found');
|
|
return files.get(filePath);
|
|
},
|
|
write: async (filePath, content) => {
|
|
files.set(filePath, content);
|
|
return files.get(filePath);
|
|
},
|
|
};
|
|
}
|
|
|
|
const adapter = createFakeAdapter();
|
|
const manager = new cacheManagerModule.CacheManager(adapter, '.obsidian', 'parallel-reader', () => ({
|
|
...settings.DEFAULT_SETTINGS,
|
|
maxCacheEntries: 2,
|
|
}));
|
|
adapter.files.set(
|
|
manager.filePath(),
|
|
JSON.stringify({
|
|
version: 1,
|
|
entries: {
|
|
'old.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [] },
|
|
'fresh.md': { generatedAt: '2024-01-02T00:00:00.000Z', cards: [] },
|
|
'touched.md': {
|
|
generatedAt: '2024-01-03T00:00:00.000Z',
|
|
lastAccessedAt: '2024-02-01T00:00:00.000Z',
|
|
cards: [],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await manager.load();
|
|
assert.strictEqual(manager.cache['old.md'], undefined, 'CacheManager.load prunes old entries');
|
|
assert.ok(adapter.files.get(manager.filePath()).includes('fresh.md'), 'CacheManager.load persists prune results');
|
|
|
|
const touchedEntry = await manager.touch('fresh.md');
|
|
assert.ok(touchedEntry.lastAccessedAt, 'CacheManager.touch updates existing entries');
|
|
await manager.flush();
|
|
assert.ok(JSON.parse(adapter.files.get(manager.filePath())).entries['fresh.md'].lastAccessedAt);
|
|
|
|
assert.strictEqual(
|
|
await manager.replaceCards('fresh.md', [{ title: 'New', anchor: 'A', gist: 'G', bullets: ['B'], level: 2, startLine: 1 }]),
|
|
true,
|
|
'CacheManager.replaceCards updates existing entries',
|
|
);
|
|
assert.strictEqual(JSON.parse(adapter.files.get(manager.filePath())).entries['fresh.md'].cards[0].title, 'New');
|
|
|
|
await manager.delete('fresh.md');
|
|
assert.strictEqual(manager.cache['fresh.md'], undefined, 'CacheManager.delete removes entries');
|
|
|
|
manager.cache = { 'clear.md': { generatedAt: '2024-01-04T00:00:00.000Z', cards: [] } };
|
|
await manager.save();
|
|
assert.ok(adapter.files.get(manager.filePath()).includes('clear.md'), 'CacheManager.save persists current cache');
|
|
await manager.clear();
|
|
assert.deepStrictEqual(manager.cache, {}, 'CacheManager.clear resets cache state');
|
|
|
|
assert.strictEqual(
|
|
generation.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }),
|
|
'cancelRequestedApiInFlight',
|
|
'direct generation import exposes API cancellation semantics',
|
|
);
|
|
assert.strictEqual(
|
|
generation.cancellationNoticeKey({ backend: 'codex' }, { phase: 'generating' }),
|
|
'cancelRequested',
|
|
'direct generation import exposes CLI cancellation semantics',
|
|
);
|
|
|
|
const providerCardsJson = JSON.stringify({ cards: [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }] });
|
|
assert.strictEqual(
|
|
providerParsers.textFromOpenAiChatResponse({ choices: [{ message: { content: [{ text: providerCardsJson }] } }] }),
|
|
providerCardsJson,
|
|
'direct provider parser import extracts OpenAI Chat text',
|
|
);
|
|
assert.strictEqual(
|
|
providerParsers.textFromAnthropicMessagesResponse({ content: [{ type: 'text', text: providerCardsJson }] }),
|
|
providerCardsJson,
|
|
'direct provider parser import extracts Anthropic text',
|
|
);
|
|
assert.strictEqual(
|
|
providerParsers.textFromOpenAiResponsesResponse({
|
|
output: [{ content: [{ type: 'output_text', text: providerCardsJson }] }],
|
|
}),
|
|
providerCardsJson,
|
|
'direct provider parser import extracts OpenAI Responses text',
|
|
);
|
|
assert.strictEqual(
|
|
providerParsers.textFromGoogleGenerativeAiResponse({
|
|
candidates: [{ content: { parts: [{ text: providerCardsJson }] } }],
|
|
}),
|
|
providerCardsJson,
|
|
'direct provider parser import extracts Gemini text',
|
|
);
|
|
assert.deepStrictEqual(
|
|
providerParsers.cardsFromAnthropicToolUse({
|
|
content: [{ type: 'tool_use', name: 'record_parallel_reader_cards', input: JSON.parse(providerCardsJson) }],
|
|
}),
|
|
[{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }],
|
|
'direct provider parser import extracts Anthropic tool-use cards',
|
|
);
|
|
|
|
assert.notStrictEqual(
|
|
settings.generationFingerprint({ ...settings.DEFAULT_SETTINGS, model: 'a' }),
|
|
settings.generationFingerprint({ ...settings.DEFAULT_SETTINGS, model: 'b' }),
|
|
'direct settings import exposes generation fingerprinting',
|
|
);
|
|
|
|
function trackedSignal() {
|
|
const controller = new AbortController();
|
|
const signal = controller.signal;
|
|
let activeListeners = 0;
|
|
const addEventListener = signal.addEventListener.bind(signal);
|
|
const removeEventListener = signal.removeEventListener.bind(signal);
|
|
signal.addEventListener = (type, listener, options) => {
|
|
if (type === 'abort') activeListeners++;
|
|
return addEventListener(type, listener, options);
|
|
};
|
|
signal.removeEventListener = (type, listener, options) => {
|
|
if (type === 'abort') activeListeners--;
|
|
return removeEventListener(type, listener, options);
|
|
};
|
|
return { controller, signal, activeListeners: () => activeListeners };
|
|
}
|
|
|
|
function streamingBody(text) {
|
|
const encoder = new TextEncoder();
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(text));
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
try {
|
|
const success = trackedSignal();
|
|
globalThis.fetch = async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
body: streamingBody('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n'),
|
|
text: async () => '',
|
|
});
|
|
await streaming.streamingFetch(
|
|
'https://example.test',
|
|
{},
|
|
{},
|
|
streaming.deltaExtractorForFormat('openai-chat'),
|
|
undefined,
|
|
success.signal,
|
|
{ streamingTimeoutMs: 1000 },
|
|
);
|
|
assert.strictEqual(success.activeListeners(), 0, 'streamingFetch removes abort listener after success');
|
|
|
|
const httpError = trackedSignal();
|
|
globalThis.fetch = async () => ({ ok: false, status: 500, body: null, text: async () => 'bad' });
|
|
await assert.rejects(
|
|
() =>
|
|
streaming.streamingFetch(
|
|
'https://example.test',
|
|
{},
|
|
{},
|
|
streaming.deltaExtractorForFormat('openai-chat'),
|
|
undefined,
|
|
httpError.signal,
|
|
{ streamingTimeoutMs: 1000 },
|
|
),
|
|
/HTTP 500|API returned HTTP 500/,
|
|
'streamingFetch rejects HTTP errors',
|
|
);
|
|
assert.strictEqual(httpError.activeListeners(), 0, 'streamingFetch removes abort listener after HTTP error');
|
|
|
|
const timeout = trackedSignal();
|
|
globalThis.fetch = async () => new Promise(() => {});
|
|
await assert.rejects(
|
|
() =>
|
|
streaming.streamingFetch(
|
|
'https://example.test',
|
|
{},
|
|
{},
|
|
streaming.deltaExtractorForFormat('openai-chat'),
|
|
undefined,
|
|
timeout.signal,
|
|
{ streamingTimeoutMs: 1 },
|
|
),
|
|
/Streaming timed out after 1ms/,
|
|
'streamingFetch rejects on timeout',
|
|
);
|
|
assert.strictEqual(timeout.activeListeners(), 0, 'streamingFetch removes abort listener after timeout');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
console.log('direct module tests passed');
|
|
} finally {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
})().catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
});
|