一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
462 行
18 KiB
TypeScript
462 行
18 KiB
TypeScript
import { setupDatabaseAndSynchronizer, switchClient, db, msleep, withWarningSilenced } from '../../testing/test-utils';
|
|
import Setting from '../../models/Setting';
|
|
import Folder from '../../models/Folder';
|
|
import Note from '../../models/Note';
|
|
import ItemChange from '../../models/ItemChange';
|
|
import NoteEmbedding from '../../models/NoteEmbedding';
|
|
import AiService from './AiService';
|
|
import EmbeddingIndexer, { coarseStateFrom } from './EmbeddingIndexer';
|
|
import { IndexStatus } from './types';
|
|
import TestEmbeddingProvider from './testing/TestEmbeddingProvider';
|
|
|
|
// Note.save() and Note.delete() record ItemChange rows fire-and-forget
|
|
// (`void ItemChange.add(...)`). In production that's fine because the indexer
|
|
// runs on a 5-minute timer. In tests we need to wait for the write to land.
|
|
const waitForChangesSince = async (changeId: number, expectedCount: number) => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const changes = await ItemChange.changesSinceId(changeId);
|
|
if (changes.length >= expectedCount) return;
|
|
await msleep(20);
|
|
}
|
|
throw new Error(`Timed out waiting for ${expectedCount} ItemChange row(s) past ${changeId}`);
|
|
};
|
|
|
|
// End-to-end pipeline test: create notes → run the indexer → confirm
|
|
// similaritySearch returns the right note via the test embedding provider.
|
|
// Uses the test stub provider so no real model is needed.
|
|
|
|
describe('EmbeddingIndexer', () => {
|
|
|
|
let provider: TestEmbeddingProvider;
|
|
|
|
beforeEach(async () => {
|
|
await setupDatabaseAndSynchronizer(1);
|
|
await switchClient(1);
|
|
provider = new TestEmbeddingProvider();
|
|
AiService.instance().setEmbeddingProvider(provider);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
AiService.instance().setEmbeddingProvider(null);
|
|
await EmbeddingIndexer.instance().stopRunInBackground();
|
|
});
|
|
|
|
const skipIfNoVec = () => {
|
|
if (!db(1).sqliteVecAvailable()) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('Skipping EmbeddingIndexer test: sqlite-vec unavailable');
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
it('does nothing when no provider is installed', async () => {
|
|
if (skipIfNoVec()) return;
|
|
AiService.instance().setEmbeddingProvider(null);
|
|
const folder = await Folder.save({ title: 'f' });
|
|
await Note.save({ title: 't', body: 'cats are nice', parent_id: folder.id });
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(0);
|
|
});
|
|
|
|
it('ai.embedding.enabled defaults to true', async () => {
|
|
// Indexing should be on by design when AI is enabled, with the toggle
|
|
// available as a kill switch for users who want chat-only.
|
|
expect(Setting.value('ai.embedding.enabled')).toBe(true);
|
|
});
|
|
|
|
it('indexes a new note end-to-end via the test provider', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
const note = await Note.save({
|
|
title: 'About cats',
|
|
body: 'Cats and kittens are wonderful animals',
|
|
parent_id: folder.id,
|
|
});
|
|
await waitForChangesSince(0, 1);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
const rows = await NoteEmbedding.byNoteId(note.id);
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
expect(rows[0].model_id).toBe('test-model-v1');
|
|
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(1);
|
|
});
|
|
|
|
it('returns the more relevant note first from similaritySearch', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
const catNote = await Note.save({
|
|
title: 'cats',
|
|
body: 'Cats and kittens love to play and sleep all day',
|
|
parent_id: folder.id,
|
|
});
|
|
await Note.save({
|
|
title: 'cars',
|
|
body: 'Engines and pistons need regular maintenance and oil',
|
|
parent_id: folder.id,
|
|
});
|
|
await waitForChangesSince(0, 2);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
const [queryVec] = await provider.embed(['kittens']);
|
|
const results = await NoteEmbedding.similaritySearch(queryVec, { k: 5 });
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
// The first match must come from the cat note, not the car note.
|
|
expect(results[0].noteId).toBe(catNote.id);
|
|
});
|
|
|
|
it('removes embeddings for a deleted note on the next maintenance', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
const note = await Note.save({ title: 't', body: 'some body text', parent_id: folder.id });
|
|
await waitForChangesSince(0, 1);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
expect(await NoteEmbedding.countByNoteId(note.id)).toBeGreaterThan(0);
|
|
|
|
const cursorAfterFirst = Setting.value('ai.embedding.lastProcessedChangeId') as number;
|
|
await Note.delete(note.id);
|
|
await waitForChangesSince(cursorAfterFirst, 1);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
expect(await NoteEmbedding.countByNoteId(note.id)).toBe(0);
|
|
});
|
|
|
|
it('removes embeddings for a locked note on the next maintenance', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
const note = await Note.save({ title: 't', body: 'some body text', parent_id: folder.id });
|
|
await waitForChangesSince(0, 1);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
expect(await NoteEmbedding.countByNoteId(note.id)).toBeGreaterThan(0);
|
|
|
|
const cursorAfterFirst = Setting.value('ai.embedding.lastProcessedChangeId') as number;
|
|
await Note.save({ id: note.id, is_locked: 1 });
|
|
await waitForChangesSince(cursorAfterFirst, 1);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
expect(await NoteEmbedding.countByNoteId(note.id)).toBe(0);
|
|
});
|
|
|
|
it('skips trashed and conflict notes', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
const trashedNote = await Note.save({
|
|
title: 'trashed',
|
|
body: 'this is in the trash',
|
|
parent_id: folder.id,
|
|
});
|
|
await Note.save({ ...trashedNote, deleted_time: Date.now() });
|
|
|
|
const conflictNote = await Note.save({
|
|
title: 'conflict',
|
|
body: 'this is a conflict',
|
|
parent_id: folder.id,
|
|
is_conflict: 1,
|
|
});
|
|
await waitForChangesSince(0, 2);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
expect(await NoteEmbedding.countByNoteId(trashedNote.id)).toBe(0);
|
|
expect(await NoteEmbedding.countByNoteId(conflictNote.id)).toBe(0);
|
|
});
|
|
|
|
it('skips notes with neither a title nor a body', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
const note = await Note.save({ title: '', body: '', parent_id: folder.id });
|
|
await waitForChangesSince(0, 1);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
expect(await NoteEmbedding.countByNoteId(note.id)).toBe(0);
|
|
});
|
|
|
|
it('indexes a note that has only a title (empty body)', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
const note = await Note.save({ title: 'My therapist phone number', body: '', parent_id: folder.id });
|
|
await waitForChangesSince(0, 1);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
// A note with a title only is real signal — the user can ask "what's my
|
|
// therapist's number" and expect this to come up. So it must be indexed.
|
|
expect(await NoteEmbedding.countByNoteId(note.id)).toBe(1);
|
|
});
|
|
|
|
it('embeds the title alongside the body so title-anchored queries match', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
// Body has nothing about dogs — only the title carries that signal.
|
|
// Without title indexing, this note would be invisible to "dog walker".
|
|
const dogNote = await Note.save({
|
|
title: 'Pet sitters for my dog',
|
|
body: 'see attached pdf for contact details',
|
|
parent_id: folder.id,
|
|
});
|
|
await Note.save({
|
|
title: 'shopping list',
|
|
body: 'milk eggs bread butter cheese',
|
|
parent_id: folder.id,
|
|
});
|
|
await waitForChangesSince(0, 2);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
const [queryVec] = await provider.embed(['dog walker']);
|
|
const results = await NoteEmbedding.similaritySearch(queryVec, { k: 5 });
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results[0].noteId).toBe(dogNote.id);
|
|
});
|
|
|
|
it('advances the change-id cursor', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
await Note.save({ title: 't', body: 'something', parent_id: folder.id });
|
|
await waitForChangesSince(0, 1);
|
|
|
|
expect(Setting.value('ai.embedding.lastProcessedChangeId')).toBe(0);
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
expect(Setting.value('ai.embedding.lastProcessedChangeId') as number).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('clears the index and resets the cursor when the model id changes', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
await Note.save({ title: 't', body: 'something to index', parent_id: folder.id });
|
|
await waitForChangesSince(0, 1);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(1);
|
|
|
|
// Swap the provider for one with a different modelId — simulates the
|
|
// user picking a different embedding model.
|
|
AiService.instance().setEmbeddingProvider(new TestEmbeddingProvider({ modelId: 'test-model-v2' }));
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
// The whole index should be rebuilt under the new model.
|
|
expect(Setting.value('ai.embedding.lastIndexedModelId')).toBe('test-model-v2');
|
|
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(1);
|
|
});
|
|
|
|
it('does not concurrently overlap maintenance runs', async () => {
|
|
if (skipIfNoVec()) return;
|
|
// Two maintenance calls back-to-back should be safe even when the
|
|
// first hasn't returned yet — the second short-circuits via the
|
|
// in-flight flag.
|
|
const folder = await Folder.save({ title: 'f' });
|
|
await Note.save({ title: 't', body: 'body', parent_id: folder.id });
|
|
await waitForChangesSince(0, 1);
|
|
|
|
await Promise.all([
|
|
EmbeddingIndexer.instance().maintenance(),
|
|
EmbeddingIndexer.instance().maintenance(),
|
|
]);
|
|
|
|
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(1);
|
|
});
|
|
|
|
it('getStatus reports unavailable when no provider is active', async () => {
|
|
AiService.instance().setEmbeddingProvider(null);
|
|
const status = await EmbeddingIndexer.instance().getStatus();
|
|
expect(status.modelDownloadStatus).toBe('unavailable');
|
|
});
|
|
|
|
it('getStatus distinguishes between AI-off and indexing-off', async () => {
|
|
Setting.setValue('ai.enabled', false);
|
|
try {
|
|
const status = await EmbeddingIndexer.instance().getStatus();
|
|
expect(status.indexerState).toBe('ai-disabled');
|
|
} finally {
|
|
Setting.setValue('ai.enabled', true);
|
|
}
|
|
|
|
Setting.setValue('ai.embedding.enabled', false);
|
|
try {
|
|
const status = await EmbeddingIndexer.instance().getStatus();
|
|
expect(status.indexerState).toBe('index-disabled');
|
|
} finally {
|
|
Setting.setValue('ai.embedding.enabled', true);
|
|
}
|
|
});
|
|
|
|
it('getStatus reports downloaded for providers without a download stage', async () => {
|
|
// The bigram test provider doesn't expose modelDownloadStatus, so the
|
|
// reporter should treat it as "always ready" — i.e. 'downloaded' from
|
|
// the UI's point of view.
|
|
const status = await EmbeddingIndexer.instance().getStatus();
|
|
expect(status.modelDownloadStatus).toBe('downloaded');
|
|
});
|
|
|
|
it('backfills notes whose item_changes are already past the cursor', async () => {
|
|
if (skipIfNoVec()) return;
|
|
// Simulate "existing notes on first AI enable": the notes have
|
|
// already produced item_changes, but the cursor is set past them as
|
|
// if the indexer had nothing to chew on from the change feed.
|
|
const folder = await Folder.save({ title: 'f' });
|
|
await Note.save({ title: 'A', body: 'apples', parent_id: folder.id });
|
|
await Note.save({ title: 'B', body: 'bananas', parent_id: folder.id });
|
|
const locked = await Note.save({ title: 'C', body: 'cherries', parent_id: folder.id, is_locked: 1 });
|
|
await waitForChangesSince(0, 3);
|
|
const lastChange = await ItemChange.lastChangeId();
|
|
Setting.setValue('ai.embedding.lastProcessedChangeId', lastChange);
|
|
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
// The unlocked notes should be indexed via the backfill path even though no
|
|
// new item_changes were available.
|
|
expect(await NoteEmbedding.distinctNoteIdCount()).toBe(2);
|
|
expect(await NoteEmbedding.countByNoteId(locked.id)).toBe(0);
|
|
expect(Setting.value('ai.embedding.initialScanDone')).toBe(true);
|
|
expect((await EmbeddingIndexer.instance().getStatus()).totalNotes).toBe(2);
|
|
});
|
|
|
|
it('skips a note that fails during the initial scan instead of looping on it', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
const good = await Note.save({ title: 'good', body: 'this one works', parent_id: folder.id });
|
|
const bad = await Note.save({ title: 'bad', body: 'this one will fail', parent_id: folder.id });
|
|
await waitForChangesSince(0, 2);
|
|
|
|
// Provider that throws for the bad note's body, succeeds for everything else.
|
|
let failureCalls = 0;
|
|
const failingProvider = new (class extends TestEmbeddingProvider {
|
|
public async embed(texts: string[]) {
|
|
if (texts.some(t => t.includes('this one will fail'))) {
|
|
failureCalls++;
|
|
throw new Error('synthetic failure');
|
|
}
|
|
return super.embed(texts);
|
|
}
|
|
})();
|
|
AiService.instance().setEmbeddingProvider(failingProvider);
|
|
|
|
// First tick processes both — bad fails, good succeeds.
|
|
await withWarningSilenced(/synthetic failure/, async () => {
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
});
|
|
// Second tick must NOT retry the bad note (would re-incur failureCalls).
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
expect(failureCalls).toBe(1);
|
|
expect(await NoteEmbedding.countByNoteId(good.id)).toBeGreaterThan(0);
|
|
expect(await NoteEmbedding.countByNoteId(bad.id)).toBe(0);
|
|
expect(Setting.value('ai.embedding.initialScanDone')).toBe(true);
|
|
});
|
|
|
|
it('does not start in background when vector search is unavailable', async () => {
|
|
// On platforms where the sqlite-vec extension fails to load (see
|
|
// #15761) the indexer would otherwise pull every note into the
|
|
// expensive embedding provider and then throw at saveChunks — burning
|
|
// CPU/memory on every tick for no result. Confirm we bail at startup
|
|
// and surface the state to the UI instead.
|
|
Setting.setValue('ai.enabled', true);
|
|
const spy = jest.spyOn(NoteEmbedding, 'vectorSearchAvailable').mockReturnValue(false);
|
|
try {
|
|
await withWarningSilenced(/Not starting background indexer: sqlite-vec extension is not loaded/, async () => {
|
|
await EmbeddingIndexer.instance().runInBackground();
|
|
});
|
|
const status = await EmbeddingIndexer.instance().getStatus();
|
|
expect(status.indexerState).toBe('vector-search-unavailable');
|
|
} finally {
|
|
spy.mockRestore();
|
|
Setting.setValue('ai.enabled', false);
|
|
await EmbeddingIndexer.instance().stopRunInBackground();
|
|
}
|
|
});
|
|
|
|
test.each([
|
|
{ name: 'unavailable wins over disabled',
|
|
status: { indexerState: 'vector-search-unavailable', modelDownloadStatus: 'unavailable', notesIndexed: 0, totalNotes: 0 },
|
|
expected: 'unavailable' },
|
|
{ name: 'ai-disabled → disabled',
|
|
status: { indexerState: 'ai-disabled', modelDownloadStatus: 'downloaded', notesIndexed: 0, totalNotes: 0 },
|
|
expected: 'disabled' },
|
|
{ name: 'index-disabled → disabled',
|
|
status: { indexerState: 'index-disabled', modelDownloadStatus: 'downloaded', notesIndexed: 0, totalNotes: 0 },
|
|
expected: 'disabled' },
|
|
{ name: 'no provider but enabled → preparing',
|
|
status: { indexerState: 'idle', modelDownloadStatus: 'unavailable', notesIndexed: 0, totalNotes: 0 },
|
|
expected: 'preparing' },
|
|
{ name: 'model downloading → preparing',
|
|
status: { indexerState: 'idle', modelDownloadStatus: 'downloading', notesIndexed: 0, totalNotes: 0 },
|
|
expected: 'preparing' },
|
|
{ name: 'model not-started → preparing',
|
|
status: { indexerState: 'idle', modelDownloadStatus: 'not-started', notesIndexed: 0, totalNotes: 0 },
|
|
expected: 'preparing' },
|
|
{ name: 'running with model ready → indexing',
|
|
status: { indexerState: 'running', modelDownloadStatus: 'downloaded', notesIndexed: 1, totalNotes: 5 },
|
|
expected: 'indexing' },
|
|
{ name: 'idle with model ready → ready',
|
|
status: { indexerState: 'idle', modelDownloadStatus: 'downloaded', notesIndexed: 3, totalNotes: 3 },
|
|
expected: 'ready' },
|
|
] as { name: string; status: IndexStatus; expected: string }[])('coarseStateFrom: $name', ({ status, expected }) => {
|
|
expect(coarseStateFrom(status)).toBe(expected);
|
|
});
|
|
|
|
it('getPluginStatus: ready is false until at least one note is indexed', async () => {
|
|
if (skipIfNoVec()) return;
|
|
Setting.setValue('ai.enabled', true);
|
|
// note_embeddings isn't reset by test-utils — start clean.
|
|
await NoteEmbedding.clearAll();
|
|
try {
|
|
const before = await EmbeddingIndexer.instance().getPluginStatus();
|
|
expect(before.state).toBe('ready');
|
|
expect(before.ready).toBe(false);
|
|
expect(before.modelId).toBe(provider.modelId);
|
|
|
|
const folder = await Folder.save({ title: 'f' });
|
|
await Note.save({ title: 't', body: 'something', parent_id: folder.id });
|
|
await waitForChangesSince(0, 1);
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
const after = await EmbeddingIndexer.instance().getPluginStatus();
|
|
expect(after.state).toBe('ready');
|
|
expect(after.ready).toBe(true);
|
|
expect(after.notesIndexed).toBe(1);
|
|
} finally {
|
|
Setting.setValue('ai.enabled', false);
|
|
}
|
|
});
|
|
|
|
it('getPluginStatus: modelId is null when no provider is active', async () => {
|
|
const original = AiService.instance().getActiveEmbeddingProvider();
|
|
AiService.instance().setEmbeddingProvider(null);
|
|
try {
|
|
const status = await EmbeddingIndexer.instance().getPluginStatus();
|
|
expect(status.modelId).toBeNull();
|
|
expect(status.ready).toBe(false);
|
|
} finally {
|
|
AiService.instance().setEmbeddingProvider(original);
|
|
}
|
|
});
|
|
|
|
it('getStatus counts indexed vs total notes and excludes trashed ones', async () => {
|
|
if (skipIfNoVec()) return;
|
|
const folder = await Folder.save({ title: 'f' });
|
|
await Note.save({ title: 'A', body: 'apples are red', parent_id: folder.id });
|
|
await Note.save({ title: 'B', body: 'bananas are yellow', parent_id: folder.id });
|
|
const trashed = await Note.save({ title: 'C', body: 'cherries', parent_id: folder.id });
|
|
// Move the third note to trash so it should be excluded from totalNotes.
|
|
await Note.batchDelete([trashed.id], { toTrash: true });
|
|
await waitForChangesSince(0, 3);
|
|
await EmbeddingIndexer.instance().maintenance();
|
|
|
|
const status = await EmbeddingIndexer.instance().getStatus();
|
|
expect(status.totalNotes).toBe(2);
|
|
expect(status.notesIndexed).toBeGreaterThanOrEqual(1);
|
|
expect(status.notesIndexed).toBeLessThanOrEqual(2);
|
|
});
|
|
});
|