From d35b55f7d31df133506c4e521a4980c9ac38ec97 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 13:37:20 +0300 Subject: [PATCH 01/23] fix(server): tests failing due to SQL initialization order --- apps/server/src/services/sql.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/sql.ts b/apps/server/src/services/sql.ts index ceff5a8917..6b913ea3ff 100644 --- a/apps/server/src/services/sql.ts +++ b/apps/server/src/services/sql.ts @@ -1,3 +1,17 @@ import { getSql } from "@triliumnext/core"; -export default getSql(); +// Lazy proxy: defers getSql() until first property access. Without this, +// any static import chain that reaches this file crashes if initSql() has +// not been called yet. The server avoids that today only because main.ts +// is careful to use dynamic imports for everything that transitively +// touches sql — an unenforced invariant. Tests hit the trap because +// vitest's beforeAll runs after the test file's static imports resolve. +const sqlProxy = new Proxy({} as ReturnType, { + get(_target, prop, receiver) { + const sql = getSql(); + const value = Reflect.get(sql, prop, receiver); + return typeof value === "function" ? value.bind(sql) : value; + } +}); + +export default sqlProxy; From d34ba8b6f36ae549a0800dffbbcf8411d3d5c88b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 13:46:44 +0300 Subject: [PATCH 02/23] test(server): integration database not initialized properly --- apps/server/spec/setup.ts | 7 ++++++- apps/server/vite.config.mts | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/server/spec/setup.ts b/apps/server/spec/setup.ts index 484d582465..c84112610e 100644 --- a/apps/server/spec/setup.ts +++ b/apps/server/spec/setup.ts @@ -18,8 +18,13 @@ process.env.TRILIUM_ENV = "dev"; process.env.TRILIUM_PUBLIC_SERVER = "http://localhost:4200"; beforeAll(async () => { + // Load the integration test database into memory. The fixture at + // spec/db/document.db is pre-seeded with the schema, demo content, and + // a known password ("demo1234") that the ETAPI tests log in with. Each + // test file runs in its own vitest fork (pool: "forks"), so each gets a + // fresh in-memory copy and mutations don't leak across files. const dbProvider = new BetterSqlite3Provider(); - dbProvider.loadFromMemory(); + dbProvider.loadFromBuffer(readFileSync(join(__dirname, "db", "document.db"))); await initializeCore({ dbConfig: { diff --git a/apps/server/vite.config.mts b/apps/server/vite.config.mts index 2fa10ff798..5db1745722 100644 --- a/apps/server/vite.config.mts +++ b/apps/server/vite.config.mts @@ -13,7 +13,14 @@ export default defineConfig(() => ({ env: { NODE_ENV: "development", TRILIUM_DATA_DIR: "./spec/db", - TRILIUM_INTEGRATION_TEST: "memory" + TRILIUM_INTEGRATION_TEST: "memory", + // Must be set in the vitest env (not in spec/setup.ts) so import-time + // constants like `isDev` in apps/server/src/services/utils.ts evaluate + // correctly. setup.ts top-level statements run AFTER its static imports + // resolve, so any env var assigned there is too late for module-load + // constants in transitively-imported files. + TRILIUM_ENV: "dev", + TRILIUM_PUBLIC_SERVER: "http://localhost:4200" }, include: ['{src,spec}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], exclude: [ From 66a18d12dccc821bd276a7606c690391d3479703 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 16:37:13 +0300 Subject: [PATCH 03/23] fix(server): in-app help not integrated --- apps/desktop/src/main.ts | 4 +- apps/edit-docs/src/edit-docs.ts | 4 +- apps/server/spec/setup.ts | 4 +- apps/server/src/in_app_help_provider.ts | 165 ++++++++++++++++++ apps/server/src/main.ts | 2 + apps/server/src/services/in_app_help.spec.ts | 11 +- apps/server/src/services/in_app_help.ts | 158 ----------------- packages/trilium-core/src/index.ts | 8 +- .../trilium-core/src/services/in_app_help.ts | 22 ++- 9 files changed, 206 insertions(+), 172 deletions(-) create mode 100644 apps/server/src/in_app_help_provider.ts delete mode 100644 apps/server/src/services/in_app_help.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 61698721f9..4e214545fa 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import { getLog, initializeCore, sql_init } from "@triliumnext/core"; import ClsHookedExecutionContext from "@triliumnext/server/src/cls_provider.js"; import NodejsCryptoProvider from "@triliumnext/server/src/crypto_provider.js"; -import NodejsZipProvider from "@triliumnext/server/src/zip_provider.js"; +import NodejsInAppHelpProvider from "@triliumnext/server/src/in_app_help_provider.js"; import dataDirs from "@triliumnext/server/src/services/data_dir.js"; import options from "@triliumnext/server/src/services/options.js"; import port from "@triliumnext/server/src/services/port.js"; @@ -10,6 +10,7 @@ import tray from "@triliumnext/server/src/services/tray.js"; import windowService from "@triliumnext/server/src/services/window.js"; import WebSocketMessagingProvider from "@triliumnext/server/src/services/ws_messaging_provider.js"; import BetterSqlite3Provider from "@triliumnext/server/src/sql_provider.js"; +import NodejsZipProvider from "@triliumnext/server/src/zip_provider.js"; import { app, BrowserWindow,globalShortcut } from "electron"; import electronDebug from "electron-debug"; import electronDl from "electron-dl"; @@ -143,6 +144,7 @@ async function main() { platform: new DesktopPlatformProvider(), translations: (await import("@triliumnext/server/src/services/i18n.js")).initializeTranslations, getDemoArchive: async () => fs.readFileSync(require.resolve("@triliumnext/server/src/assets/db/demo.zip")), + inAppHelp: new NodejsInAppHelpProvider(), extraAppInfo: { nodeVersion: process.version, dataDirectory: path.resolve(dataDirs.TRILIUM_DATA_DIR) diff --git a/apps/edit-docs/src/edit-docs.ts b/apps/edit-docs/src/edit-docs.ts index 239b52a0bb..d7846b627d 100644 --- a/apps/edit-docs/src/edit-docs.ts +++ b/apps/edit-docs/src/edit-docs.ts @@ -1,7 +1,7 @@ import debounce from "@triliumnext/client/src/services/debounce.js"; import type { AdvancedExportOptions, ExportFormat } from "@triliumnext/core"; +import NodejsInAppHelpProvider from "@triliumnext/server/src/in_app_help_provider.js"; import cls from "@triliumnext/server/src/services/cls.js"; -import { parseNoteMetaFile } from "@triliumnext/server/src/services/in_app_help.js"; import type { NoteMetaFile } from "@triliumnext/server/src/services/meta/note_meta.js"; import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js"; import fs from "fs/promises"; @@ -241,7 +241,7 @@ async function cleanUpMeta(outputPath: string, minify: boolean) { } if (minify) { - const subtree = parseNoteMetaFile(meta); + const subtree = new NodejsInAppHelpProvider().parseNoteMetaFile(meta); await fs.writeFile(metaPath, JSON.stringify(subtree)); } else { await fs.writeFile(metaPath, JSON.stringify(meta, null, 4)); diff --git a/apps/server/spec/setup.ts b/apps/server/spec/setup.ts index c84112610e..4c40a4f3be 100644 --- a/apps/server/spec/setup.ts +++ b/apps/server/spec/setup.ts @@ -8,6 +8,7 @@ import NodejsCryptoProvider from "../src/crypto_provider.js"; import NodejsZipProvider from "../src/zip_provider.js"; import ServerPlatformProvider from "../src/platform_provider.js"; import BetterSqlite3Provider from "../src/sql_provider.js"; +import NodejsInAppHelpProvider from "../src/in_app_help_provider.js"; import { initializeTranslations } from "../src/services/i18n.js"; // Initialize environment variables. @@ -39,6 +40,7 @@ beforeAll(async () => { executionContext: new ClsHookedExecutionContext(), schema: readFileSync(require.resolve("@triliumnext/core/src/assets/schema.sql"), "utf-8"), platform: new ServerPlatformProvider(), - translations: initializeTranslations + translations: initializeTranslations, + inAppHelp: new NodejsInAppHelpProvider() }); }); diff --git a/apps/server/src/in_app_help_provider.ts b/apps/server/src/in_app_help_provider.ts new file mode 100644 index 0000000000..7dd6569f46 --- /dev/null +++ b/apps/server/src/in_app_help_provider.ts @@ -0,0 +1,165 @@ +import type { HiddenSubtreeItem } from "@triliumnext/commons"; +import type { InAppHelpProvider } from "@triliumnext/core"; +import fs from "fs"; +import path from "path"; + +import becca from "./becca/becca.js"; +import type BNote from "./becca/entities/bnote.js"; +import type NoteMeta from "./services/meta/note_meta.js"; +import type { NoteMetaFile } from "./services/meta/note_meta.js"; +import { RESOURCE_DIR } from "./services/resource_dir.js"; + +export default class NodejsInAppHelpProvider implements InAppHelpProvider { + + getHelpHiddenSubtreeData(): HiddenSubtreeItem[] { + const helpDir = path.join(RESOURCE_DIR, "doc_notes", "en", "User Guide"); + const metaFilePath = path.join(helpDir, "!!!meta.json"); + + try { + return JSON.parse(fs.readFileSync(metaFilePath).toString("utf-8")); + } catch (e) { + console.warn(e); + return []; + } + } + + parseNoteMetaFile(noteMetaFile: NoteMetaFile): HiddenSubtreeItem[] { + if (!noteMetaFile.files) { + console.log("No meta files"); + return []; + } + + const metaRoot = noteMetaFile.files[0]; + const parsedMetaRoot = this.parseNoteMeta(metaRoot, "/" + (metaRoot.dirFileName ?? "")); + return parsedMetaRoot?.children ?? []; + } + + parseNoteMeta(noteMeta: NoteMeta, docNameRoot: string): HiddenSubtreeItem | null { + let iconClass: string = "bx bx-file"; + const item: HiddenSubtreeItem = { + id: `_help_${noteMeta.noteId}`, + title: noteMeta.title ?? "", + type: "doc", // can change + attributes: [] + }; + + // Handle folder notes + if (!noteMeta.dataFileName) { + iconClass = "bx bx-folder"; + item.type = "book"; + } + + // Handle attributes + for (const attribute of noteMeta.attributes ?? []) { + if (attribute.name === "iconClass") { + iconClass = attribute.value; + continue; + } + + if (attribute.name === "webViewSrc") { + item.attributes?.push({ + type: "label", + name: attribute.name, + value: attribute.value + }); + } + + if (attribute.name === "shareHiddenFromTree") { + return null; + } + } + + // Handle text notes + if (noteMeta.type === "text" && noteMeta.dataFileName) { + const docPath = `${docNameRoot}/${path.basename(noteMeta.dataFileName, ".html")}`.substring(1); + item.attributes?.push({ + type: "label", + name: "docName", + value: docPath + }); + } + + // Handle web views + if (noteMeta.type === "webView") { + item.type = "webView"; + item.enforceAttributes = true; + } + + // Handle children + if (noteMeta.children) { + const children: HiddenSubtreeItem[] = []; + for (const childMeta of noteMeta.children) { + let newDocNameRoot = noteMeta.dirFileName ? `${docNameRoot}/${noteMeta.dirFileName}` : docNameRoot; + const item = this.parseNoteMeta(childMeta, newDocNameRoot); + if (item) { + children.push(item); + } + } + + item.children = children; + } + + // Handle note icon + item.attributes?.push({ + name: "iconClass", + value: iconClass, + type: "label" + }); + + return item; + } + + /** + * Iterates recursively through the help subtree that the user has and compares it against the definition + * to remove any notes that are no longer present in the latest version of the help. + * + * @param helpDefinition the hidden subtree definition for the help, to compare against the user's structure. + */ + cleanUpHelp(helpDefinition: HiddenSubtreeItem[]): void { + function getFlatIds(items: HiddenSubtreeItem | HiddenSubtreeItem[]) { + const ids: (string | string[])[] = []; + if (Array.isArray(items)) { + for (const item of items) { + ids.push(getFlatIds(item)); + } + } else { + if (items.children) { + for (const child of items.children) { + ids.push(getFlatIds(child)); + } + } + ids.push(items.id); + } + return ids.flat(); + } + + function getFlatIdsFromNote(note: BNote | null) { + if (!note) { + return []; + } + + const ids: (string | string[])[] = []; + + for (const subnote of note.getChildNotes()) { + ids.push(getFlatIdsFromNote(subnote)); + } + + ids.push(note.noteId); + return ids.flat(); + } + + const definitionHelpIds = new Set(getFlatIds(helpDefinition)); + const realHelpIds = getFlatIdsFromNote(becca.getNote("_help")); + + for (const realHelpId of realHelpIds) { + if (realHelpId === "_help") { + continue; + } + + if (!definitionHelpIds.has(realHelpId)) { + becca.getNote(realHelpId)?.deleteNote(); + } + } + } + +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 9f54b0c9bd..f452616937 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -10,6 +10,7 @@ import path from "path"; import ClsHookedExecutionContext from "./cls_provider.js"; import NodejsCryptoProvider from "./crypto_provider.js"; +import NodejsInAppHelpProvider from "./in_app_help_provider.js"; import ServerPlatformProvider from "./platform_provider.js"; import dataDirs from "./services/data_dir.js"; import port from "./services/port.js"; @@ -61,6 +62,7 @@ async function startApplication() { platform: new ServerPlatformProvider(), translations: (await import("./services/i18n.js")).initializeTranslations, getDemoArchive: async () => fs.readFileSync(require.resolve("@triliumnext/server/src/assets/db/demo.zip")), + inAppHelp: new NodejsInAppHelpProvider(), extraAppInfo: { nodeVersion: process.version, dataDirectory: path.resolve(dataDirs.TRILIUM_DATA_DIR) diff --git a/apps/server/src/services/in_app_help.spec.ts b/apps/server/src/services/in_app_help.spec.ts index a9df31c1a2..7c41d4b47a 100644 --- a/apps/server/src/services/in_app_help.spec.ts +++ b/apps/server/src/services/in_app_help.spec.ts @@ -1,7 +1,10 @@ -import { describe, expect, expectTypeOf, it } from "vitest"; -import { parseNoteMeta } from "./in_app_help.js"; +import { describe, expect, it } from "vitest"; + +import NodejsInAppHelpProvider from "../in_app_help_provider.js"; import type NoteMeta from "./meta/note_meta.js"; +const provider = new NodejsInAppHelpProvider(); + describe("In-app help", () => { it("preserves custom folder icon", () => { const meta: NoteMeta = { @@ -29,7 +32,7 @@ describe("In-app help", () => { children: [] }; - const item = parseNoteMeta(meta, "/"); + const item = provider.parseNoteMeta(meta, "/"); const icon = item?.attributes?.find((a) => a.name === "iconClass"); expect(icon?.value).toBe("bx bx-star"); }); @@ -60,7 +63,7 @@ describe("In-app help", () => { children: [] }; - const item = parseNoteMeta(meta, "/"); + const item = provider.parseNoteMeta(meta, "/"); expect(item).toBeFalsy(); }); }); diff --git a/apps/server/src/services/in_app_help.ts b/apps/server/src/services/in_app_help.ts deleted file mode 100644 index 1424493aef..0000000000 --- a/apps/server/src/services/in_app_help.ts +++ /dev/null @@ -1,158 +0,0 @@ -import path from "path"; -import fs from "fs"; -import type NoteMeta from "./meta/note_meta.js"; -import type { NoteMetaFile } from "./meta/note_meta.js"; -import type BNote from "../becca/entities/bnote.js"; -import becca from "../becca/becca.js"; -import type { HiddenSubtreeItem } from "@triliumnext/commons"; -import { RESOURCE_DIR } from "./resource_dir.js"; - -export function getHelpHiddenSubtreeData() { - const helpDir = path.join(RESOURCE_DIR, "doc_notes", "en", "User Guide"); - const metaFilePath = path.join(helpDir, "!!!meta.json"); - - try { - return JSON.parse(fs.readFileSync(metaFilePath).toString("utf-8")); - } catch (e) { - console.warn(e); - return []; - } -} - -export function parseNoteMetaFile(noteMetaFile: NoteMetaFile): HiddenSubtreeItem[] { - if (!noteMetaFile.files) { - console.log("No meta files"); - return []; - } - - const metaRoot = noteMetaFile.files[0]; - const parsedMetaRoot = parseNoteMeta(metaRoot, "/" + (metaRoot.dirFileName ?? "")); - return parsedMetaRoot?.children ?? []; -} - -export function parseNoteMeta(noteMeta: NoteMeta, docNameRoot: string): HiddenSubtreeItem | null { - let iconClass: string = "bx bx-file"; - const item: HiddenSubtreeItem = { - id: `_help_${noteMeta.noteId}`, - title: noteMeta.title ?? "", - type: "doc", // can change - attributes: [] - }; - - // Handle folder notes - if (!noteMeta.dataFileName) { - iconClass = "bx bx-folder"; - item.type = "book"; - } - - // Handle attributes - for (const attribute of noteMeta.attributes ?? []) { - if (attribute.name === "iconClass") { - iconClass = attribute.value; - continue; - } - - if (attribute.name === "webViewSrc") { - item.attributes?.push({ - type: "label", - name: attribute.name, - value: attribute.value - }); - } - - if (attribute.name === "shareHiddenFromTree") { - return null; - } - } - - // Handle text notes - if (noteMeta.type === "text" && noteMeta.dataFileName) { - const docPath = `${docNameRoot}/${path.basename(noteMeta.dataFileName, ".html")}`.substring(1); - item.attributes?.push({ - type: "label", - name: "docName", - value: docPath - }); - } - - // Handle web views - if (noteMeta.type === "webView") { - item.type = "webView"; - item.enforceAttributes = true; - } - - // Handle children - if (noteMeta.children) { - const children: HiddenSubtreeItem[] = []; - for (const childMeta of noteMeta.children) { - let newDocNameRoot = noteMeta.dirFileName ? `${docNameRoot}/${noteMeta.dirFileName}` : docNameRoot; - const item = parseNoteMeta(childMeta, newDocNameRoot); - if (item) { - children.push(item); - } - } - - item.children = children; - } - - // Handle note icon - item.attributes?.push({ - name: "iconClass", - value: iconClass, - type: "label" - }); - - return item; -} -/** - * Iterates recursively through the help subtree that the user has and compares it against the definition - * to remove any notes that are no longer present in the latest version of the help. - * - * @param helpDefinition the hidden subtree definition for the help, to compare against the user's structure. - */ -export function cleanUpHelp(helpDefinition: HiddenSubtreeItem[]) { - function getFlatIds(items: HiddenSubtreeItem | HiddenSubtreeItem[]) { - const ids: (string | string[])[] = []; - if (Array.isArray(items)) { - for (const item of items) { - ids.push(getFlatIds(item)); - } - } else { - if (items.children) { - for (const child of items.children) { - ids.push(getFlatIds(child)); - } - } - ids.push(items.id); - } - return ids.flat(); - } - - function getFlatIdsFromNote(note: BNote | null) { - if (!note) { - return []; - } - - const ids: (string | string[])[] = []; - - for (const subnote of note.getChildNotes()) { - ids.push(getFlatIdsFromNote(subnote)); - } - - ids.push(note.noteId); - return ids.flat(); - } - - const definitionHelpIds = new Set(getFlatIds(helpDefinition)); - const realHelpIds = getFlatIdsFromNote(becca.getNote("_help")); - - for (const realHelpId of realHelpIds) { - if (realHelpId === "_help") { - continue; - } - - if (!definitionHelpIds.has(realHelpId)) { - becca.getNote(realHelpId)?.deleteNote(); - } - } -} diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index d2cfdec5a7..d0551be776 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -11,6 +11,7 @@ import appInfo from "./services/app_info"; import { type PlatformProvider, initPlatform } from "./services/platform"; import { type ZipProvider, initZipProvider } from "./services/zip_provider"; import { type ZipExportProviderFactory, initZipExportProviderFactory } from "./services/export/zip_export_provider_factory"; +import { type InAppHelpProvider, initInAppHelp } from "./services/in_app_help"; export { getLog } from "./services/log"; export type * from "./services/sql/types"; @@ -96,6 +97,7 @@ export { default as content_hash } from "./services/content_hash"; export { default as sync_mutex } from "./services/sync_mutex"; export { default as setup } from "./services/setup"; export { getPlatform, type PlatformProvider } from "./services/platform"; +export type { InAppHelpProvider } from "./services/in_app_help"; export { t } from "i18next"; export type { RequestProvider, ExecOpts, CookieJar } from "./services/request"; export type * from "./meta"; @@ -119,7 +121,7 @@ export { default as scriptService } from "./services/script"; export { default as BackendScriptApi, type Api as BackendScriptApiInterface } from "./services/backend_script_api"; export * as scheduler from "./services/scheduler"; -export async function initializeCore({ dbConfig, executionContext, crypto, zip, zipExportProviderFactory, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive }: { +export async function initializeCore({ dbConfig, executionContext, crypto, zip, zipExportProviderFactory, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive, inAppHelp }: { dbConfig: SqlServiceParams, executionContext: ExecutionContext, crypto: CryptoProvider, @@ -131,6 +133,7 @@ export async function initializeCore({ dbConfig, executionContext, crypto, zip, messaging?: MessagingProvider, request?: RequestProvider, getDemoArchive?: () => Promise, + inAppHelp?: InAppHelpProvider, extraAppInfo?: { nodeVersion: string; dataDirectory: string; @@ -155,4 +158,7 @@ export async function initializeCore({ dbConfig, executionContext, crypto, zip, if (request) { initRequest(request); } + if (inAppHelp) { + initInAppHelp(inAppHelp); + } }; diff --git a/packages/trilium-core/src/services/in_app_help.ts b/packages/trilium-core/src/services/in_app_help.ts index 4f377f0a6a..2da46190f2 100644 --- a/packages/trilium-core/src/services/in_app_help.ts +++ b/packages/trilium-core/src/services/in_app_help.ts @@ -1,8 +1,20 @@ -export function cleanUpHelp(items: unknown[]) { - // TODO: implement. +import type { HiddenSubtreeItem } from "@triliumnext/commons"; + +export interface InAppHelpProvider { + getHelpHiddenSubtreeData(): HiddenSubtreeItem[]; + cleanUpHelp(items: HiddenSubtreeItem[]): void; } -export function getHelpHiddenSubtreeData() { - // TODO: implement. - return []; +let provider: InAppHelpProvider | null = null; + +export function initInAppHelp(p: InAppHelpProvider) { + provider = p; +} + +export function getHelpHiddenSubtreeData(): HiddenSubtreeItem[] { + return provider?.getHelpHiddenSubtreeData() ?? []; +} + +export function cleanUpHelp(items: HiddenSubtreeItem[]): void { + provider?.cleanUpHelp(items); } From dc0fcad843fbb07f27a5950eb34ff2a8ef7ab9ef Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 16:45:24 +0300 Subject: [PATCH 04/23] test(server): run core tests --- apps/server/vite.config.mts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/vite.config.mts b/apps/server/vite.config.mts index 5db1745722..39b0b65592 100644 --- a/apps/server/vite.config.mts +++ b/apps/server/vite.config.mts @@ -22,7 +22,10 @@ export default defineConfig(() => ({ TRILIUM_ENV: "dev", TRILIUM_PUBLIC_SERVER: "http://localhost:4200" }, - include: ['{src,spec}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + include: [ + '{src,spec}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', + '../../packages/trilium-core/src/**/*.{test,spec}.{ts,tsx}' + ], exclude: [ "spec/build-checks/**", ], From 3a7ce0c284c24536a43523d9d8b71bc4f96c0fac Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 16:55:36 +0300 Subject: [PATCH 05/23] test(core): fix initialization issues due to SQL --- ...0233__migrate_geo_map_to_collection.spec.ts | 9 ++++++++- .../trilium-core/src/services/script.spec.ts | 18 +++++++++++++----- .../services/search/value_extractor.spec.ts | 12 ++++++++++-- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/trilium-core/src/migrations/0233__migrate_geo_map_to_collection.spec.ts b/packages/trilium-core/src/migrations/0233__migrate_geo_map_to_collection.spec.ts index 6dfba236df..3251b9680b 100644 --- a/packages/trilium-core/src/migrations/0233__migrate_geo_map_to_collection.spec.ts +++ b/packages/trilium-core/src/migrations/0233__migrate_geo_map_to_collection.spec.ts @@ -19,9 +19,16 @@ import migration from "./0233__migrate_geo_map_to_collection.js"; * test data into the database, then verifies the migration transforms the data correctly. */ describe("Migration 0233: Migrate geoMap to collection", () => { - const sql = getSql(); + let sql: ReturnType; beforeEach(async () => { + // getSql() is resolved here (not at describe-collection time) so that + // initializeCore() in the test setup's beforeAll has had a chance to + // run first. Capturing it eagerly at the top of describe crashes with + // "SQL not initialized" because describe callbacks run before any + // beforeAll hooks fire. + sql = getSql(); + // Set up a clean in-memory database for each test rebuildIntegrationTestDatabase(); diff --git a/packages/trilium-core/src/services/script.spec.ts b/packages/trilium-core/src/services/script.spec.ts index 11b904d830..3002cda888 100644 --- a/packages/trilium-core/src/services/script.spec.ts +++ b/packages/trilium-core/src/services/script.spec.ts @@ -1,5 +1,5 @@ import { trimIndentation } from "@triliumnext/commons"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import becca from "../becca/becca.js"; import BBranch from "../becca/entities/bbranch.js"; @@ -61,10 +61,18 @@ describe("Script", () => { }); describe("dayjs in backend scripts", () => { - const scriptNote = buildNote({ - type: "code", - mime: "application/javascript;env=backend", - content: "" + // buildNote() is called inside beforeAll (not at describe-collection + // time) so that the test setup's initializeCore() has run first. + // buildNote generates random IDs via getCrypto(), which crashes with + // "Crypto not initialized" if invoked during describe collection. + let scriptNote: ReturnType; + + beforeAll(() => { + scriptNote = buildNote({ + type: "code", + mime: "application/javascript;env=backend", + content: "" + }); }); it("dayjs is available", () => { diff --git a/packages/trilium-core/src/services/search/value_extractor.spec.ts b/packages/trilium-core/src/services/search/value_extractor.spec.ts index 37f3d41ce3..d9828a86c6 100644 --- a/packages/trilium-core/src/services/search/value_extractor.spec.ts +++ b/packages/trilium-core/src/services/search/value_extractor.spec.ts @@ -1,10 +1,18 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import ValueExtractor from "./value_extractor.js"; import becca from "../../becca/becca.js"; import SearchContext from "./search_context.js"; import { note } from "../../test/becca_mocking.js"; -const dsc = new SearchContext(); +// SearchContext is constructed inside beforeAll (not at module load) so that +// the test setup's initializeCore() has run first. Constructing it at module +// top level crashes with "Context not initialized" because module evaluation +// happens before any beforeAll hooks fire. +let dsc: SearchContext; + +beforeAll(() => { + dsc = new SearchContext(); +}); describe("Value extractor", () => { beforeEach(() => { From f40de0a017eb5ebb9a1bc8ca28e9986b18c36dee Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 17:41:47 +0300 Subject: [PATCH 06/23] test(core): fix more tests --- apps/server/spec/setup.ts | 11 ++++++----- apps/server/src/share/sql.ts | 11 +++++++++++ apps/server/src/sql_provider.ts | 4 ++++ ...0233__migrate_geo_map_to_collection.spec.ts | 17 ++++++++++++++--- .../src/services/migration.spec.ts | 14 ++++++++++++-- .../trilium-core/src/services/sql/index.ts | 4 ---- packages/trilium-core/src/services/sql/sql.ts | 14 ++++++++++++++ .../src/test/fixtures}/document.db | Bin .../src/test/fixtures}/document_v214.db | Bin .../test/fixtures}/document_v214_migrated.db | Bin 10 files changed, 61 insertions(+), 14 deletions(-) rename {apps/server/spec/db => packages/trilium-core/src/test/fixtures}/document.db (100%) rename {apps/server/spec/db => packages/trilium-core/src/test/fixtures}/document_v214.db (100%) rename {apps/server/spec/db => packages/trilium-core/src/test/fixtures}/document_v214_migrated.db (100%) diff --git a/apps/server/spec/setup.ts b/apps/server/spec/setup.ts index 4c40a4f3be..4585f0f8c4 100644 --- a/apps/server/spec/setup.ts +++ b/apps/server/spec/setup.ts @@ -20,12 +20,13 @@ process.env.TRILIUM_PUBLIC_SERVER = "http://localhost:4200"; beforeAll(async () => { // Load the integration test database into memory. The fixture at - // spec/db/document.db is pre-seeded with the schema, demo content, and - // a known password ("demo1234") that the ETAPI tests log in with. Each - // test file runs in its own vitest fork (pool: "forks"), so each gets a - // fresh in-memory copy and mutations don't leak across files. + // packages/trilium-core/src/test/fixtures/document.db is pre-seeded with + // the schema, demo content, and a known password ("demo1234") that the + // ETAPI tests log in with. Each test file runs in its own vitest fork + // (pool: "forks"), so each gets a fresh in-memory copy and mutations + // don't leak across files. const dbProvider = new BetterSqlite3Provider(); - dbProvider.loadFromBuffer(readFileSync(join(__dirname, "db", "document.db"))); + dbProvider.loadFromBuffer(readFileSync(require.resolve("@triliumnext/core/src/test/fixtures/document.db"))); await initializeCore({ dbConfig: { diff --git a/apps/server/src/share/sql.ts b/apps/server/src/share/sql.ts index 5a127e791f..5f5e797e83 100644 --- a/apps/server/src/share/sql.ts +++ b/apps/server/src/share/sql.ts @@ -8,6 +8,17 @@ let dbConnection!: Database.Database; let dbConnectionReady = false; sql_init.dbReady.then(() => { + // The share module opens its own read-only connection to the on-disk + // database for isolation from the main read/write connection. In + // integration test mode the database is in-memory (loaded from a + // fixture buffer) and no file exists on disk, so opening one would + // throw SQLITE_CANTOPEN. Tests that exercise share functionality + // would need a different approach; skipping here keeps unrelated + // test files from failing on an unhandled rejection. + if (process.env.TRILIUM_INTEGRATION_TEST) { + return; + } + dbConnection = new Database(dataDir.DOCUMENT_PATH, { readonly: true, nativeBinding: process.env.BETTERSQLITE3_NATIVE_PATH || undefined diff --git a/apps/server/src/sql_provider.ts b/apps/server/src/sql_provider.ts index 86adb5b2c9..8195e08060 100644 --- a/apps/server/src/sql_provider.ts +++ b/apps/server/src/sql_provider.ts @@ -31,6 +31,10 @@ export default class BetterSqlite3Provider implements DatabaseProvider { } loadFromBuffer(buffer: NonSharedBuffer) { + // Close any existing connection so its file handles are released + // before we replace it. Important for repeated rebuilds in tests + // (each call would otherwise leak the previous handle). + this.dbConnection?.close(); this.dbConnection = new Database(buffer, dbOpts); } diff --git a/packages/trilium-core/src/migrations/0233__migrate_geo_map_to_collection.spec.ts b/packages/trilium-core/src/migrations/0233__migrate_geo_map_to_collection.spec.ts index 3251b9680b..506779e629 100644 --- a/packages/trilium-core/src/migrations/0233__migrate_geo_map_to_collection.spec.ts +++ b/packages/trilium-core/src/migrations/0233__migrate_geo_map_to_collection.spec.ts @@ -1,10 +1,19 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it, beforeEach } from "vitest"; import * as cls from "../services/context.js"; -import { getSql, rebuildIntegrationTestDatabase } from "../services/sql/index.js"; +import { getSql } from "../services/sql/index.js"; import becca from "../becca/becca.js"; import becca_loader from "../becca/becca_loader.js"; import migration from "./0233__migrate_geo_map_to_collection.js"; +// Resolve fixture path relative to this spec file. Spec files only ever run +// under vitest (which uses ESM via Vite), so import.meta.url is available; +// the CLAUDE.md restriction against import.meta.url applies to production +// code that gets bundled to CJS, not to test files. +const __dirname = dirname(fileURLToPath(import.meta.url)); + /** * Test suite for migration 0233 which converts geoMap notes to book type with viewConfig attachments. * @@ -29,8 +38,10 @@ describe("Migration 0233: Migrate geoMap to collection", () => { // beforeAll hooks fire. sql = getSql(); - // Set up a clean in-memory database for each test - rebuildIntegrationTestDatabase(); + // Reload the integration test database from the fixture for each test + // so mutations from one test don't leak into the next. + const dbBytes = readFileSync(join(__dirname, "../test/fixtures/document.db")); + sql.rebuildFromBuffer(dbBytes); await new Promise((resolve) => { cls.getContext().init(() => { diff --git a/packages/trilium-core/src/services/migration.spec.ts b/packages/trilium-core/src/services/migration.spec.ts index c88bbfb824..4961843f30 100644 --- a/packages/trilium-core/src/services/migration.spec.ts +++ b/packages/trilium-core/src/services/migration.spec.ts @@ -1,12 +1,22 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { getContext } from "./context.js"; +import { getSql } from "./sql/index.js"; + +// Resolve fixture path relative to this spec file. Spec files only ever run +// under vitest (which uses ESM via Vite), so import.meta.url is available; +// the CLAUDE.md restriction against import.meta.url applies to production +// code that gets bundled to CJS, not to test files. +const __dirname = dirname(fileURLToPath(import.meta.url)); describe("Migration", () => { it("migrates from v214", async () => { await new Promise((resolve) => { getContext().init(async () => { - const { getSql, rebuildIntegrationTestDatabase } = (await (import("./sql/index.js"))); - rebuildIntegrationTestDatabase("spec/db/document_v214.db"); + const dbBytes = readFileSync(join(__dirname, "../test/fixtures/document_v214.db")); + getSql().rebuildFromBuffer(dbBytes); const migration = (await import("./migration.js")).default; await migration.migrateIfNecessary(); diff --git a/packages/trilium-core/src/services/sql/index.ts b/packages/trilium-core/src/services/sql/index.ts index 9b6516df8b..09d06265f5 100644 --- a/packages/trilium-core/src/services/sql/index.ts +++ b/packages/trilium-core/src/services/sql/index.ts @@ -13,7 +13,3 @@ export function getSql(): SqlService { if (!sql) throw new Error("SQL not initialized"); return sql; } - -export function rebuildIntegrationTestDatabase(path?: string) { - throw new Error("Not implemented"); -} diff --git a/packages/trilium-core/src/services/sql/sql.ts b/packages/trilium-core/src/services/sql/sql.ts index 90631c5bfd..5d9a655417 100644 --- a/packages/trilium-core/src/services/sql/sql.ts +++ b/packages/trilium-core/src/services/sql/sql.ts @@ -27,6 +27,20 @@ export class SqlService { this.params = restParams; } + /** + * Replace the underlying database with a fresh in-memory copy of the + * given buffer. Used by integration tests that need a clean DB per test. + * + * Clears the prepared-statement cache because cached statements are + * bound to the previous connection and become invalid after the swap. + * + * Not safe to call inside a transaction. + */ + rebuildFromBuffer(buffer: Uint8Array) { + this.statementCache = {}; + this.dbConnection.loadFromBuffer(buffer); + } + insert(tableName: string, rec: T, replace = false) { const keys = Object.keys(rec || {}); if (keys.length === 0) { diff --git a/apps/server/spec/db/document.db b/packages/trilium-core/src/test/fixtures/document.db similarity index 100% rename from apps/server/spec/db/document.db rename to packages/trilium-core/src/test/fixtures/document.db diff --git a/apps/server/spec/db/document_v214.db b/packages/trilium-core/src/test/fixtures/document_v214.db similarity index 100% rename from apps/server/spec/db/document_v214.db rename to packages/trilium-core/src/test/fixtures/document_v214.db diff --git a/apps/server/spec/db/document_v214_migrated.db b/packages/trilium-core/src/test/fixtures/document_v214_migrated.db similarity index 100% rename from apps/server/spec/db/document_v214_migrated.db rename to packages/trilium-core/src/test/fixtures/document_v214_migrated.db From 6e0e7847e4c876a8571b861afbcb3334f322db10 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 17:46:09 +0300 Subject: [PATCH 07/23] fix(server): share tests no longer working --- apps/server/src/share/sql.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/src/share/sql.ts b/apps/server/src/share/sql.ts index 5f5e797e83..e5d03e63b4 100644 --- a/apps/server/src/share/sql.ts +++ b/apps/server/src/share/sql.ts @@ -10,16 +10,16 @@ let dbConnectionReady = false; sql_init.dbReady.then(() => { // The share module opens its own read-only connection to the on-disk // database for isolation from the main read/write connection. In - // integration test mode the database is in-memory (loaded from a - // fixture buffer) and no file exists on disk, so opening one would - // throw SQLITE_CANTOPEN. Tests that exercise share functionality - // would need a different approach; skipping here keeps unrelated - // test files from failing on an unhandled rejection. - if (process.env.TRILIUM_INTEGRATION_TEST) { - return; - } + // integration test mode `dataDir.DOCUMENT_PATH` doesn't contain a real + // database (the main connection is in-memory, loaded from a fixture + // buffer), so we open the fixture file directly from its new location + // in core. The share connection still gets its own independent + // read-only handle, matching production semantics. + const dbPath = process.env.TRILIUM_INTEGRATION_TEST + ? require.resolve("@triliumnext/core/src/test/fixtures/document.db") + : dataDir.DOCUMENT_PATH; - dbConnection = new Database(dataDir.DOCUMENT_PATH, { + dbConnection = new Database(dbPath, { readonly: true, nativeBinding: process.env.BETTERSQLITE3_NATIVE_PATH || undefined }); From acf9aa8b419bbd85df135ead4f1450516ab9a777 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 17:52:48 +0300 Subject: [PATCH 08/23] fix(core): issues with some utils --- .../trilium-core/src/services/utils/index.spec.ts | 11 +++++++++-- packages/trilium-core/src/services/utils/index.ts | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/trilium-core/src/services/utils/index.spec.ts b/packages/trilium-core/src/services/utils/index.spec.ts index da3991cb2a..398cad7161 100644 --- a/packages/trilium-core/src/services/utils/index.spec.ts +++ b/packages/trilium-core/src/services/utils/index.spec.ts @@ -530,8 +530,15 @@ describe("#safeExtractMessageAndStackFromError", () => { expect(actual[1]).not.toBeUndefined(); }); - it("should use the fallback 'Unknown Error' message, if it gets passed anything else than an instance of an Error", () => { - const testNonError = "this is not an instance of an Error, but JS technically allows us to throw this anyways"; + it("should pass a thrown string through as the message, with no stack", () => { + const testString = "this is not an instance of an Error, but JS technically allows us to throw this anyways"; + const actual = utils.safeExtractMessageAndStackFromError(testString); + expect(actual[0]).toBe(testString); + expect(actual[1]).toBeUndefined(); + }); + + it("should use the fallback 'Unknown Error' message, if it gets passed something that is neither an Error nor a string", () => { + const testNonError = { not: "an error" }; const actual = utils.safeExtractMessageAndStackFromError(testNonError); expect(actual[0]).toBe("Unknown Error"); expect(actual[1]).toBeUndefined(); diff --git a/packages/trilium-core/src/services/utils/index.ts b/packages/trilium-core/src/services/utils/index.ts index 3672b683ef..0869e28bce 100644 --- a/packages/trilium-core/src/services/utils/index.ts +++ b/packages/trilium-core/src/services/utils/index.ts @@ -211,7 +211,7 @@ export const escapeHtml = escape; export const unescapeHtml = unescape; export function randomSecureToken(bytes = 32) { - return encodeBase64(getCrypto().randomBytes(32)); + return encodeBase64(getCrypto().randomBytes(bytes)); } export function safeExtractMessageAndStackFromError(err: unknown): [errMessage: string, errStack: string | undefined] { From bfb9df48b1abb0fd2825778bab023e62eab55716 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 17:53:06 +0300 Subject: [PATCH 09/23] test(ocr): broken due to change in architecture --- .../services/search/search_result_ocr.spec.ts | 141 ++++++++---------- 1 file changed, 62 insertions(+), 79 deletions(-) diff --git a/packages/trilium-core/src/services/search/search_result_ocr.spec.ts b/packages/trilium-core/src/services/search/search_result_ocr.spec.ts index 864e11cb72..ae8af06b19 100644 --- a/packages/trilium-core/src/services/search/search_result_ocr.spec.ts +++ b/packages/trilium-core/src/services/search/search_result_ocr.spec.ts @@ -1,92 +1,75 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it } from "vitest"; -const mockBecca = { - notes: {} as Record, - getNote: vi.fn() -}; +import becca from "../../becca/becca.js"; +import BNote from "../../becca/entities/bnote.js"; +import { buildNote } from "../../test/becca_easy_mocking.js"; +import SearchResult from "./search_result.js"; -const mockBeccaService = { - getNoteTitleForPath: vi.fn() -}; +describe("SearchResult", () => { + let note: BNote; -vi.mock('../../becca/becca.js', () => ({ - default: mockBecca -})); - -vi.mock('../../becca/becca_service.js', () => ({ - default: mockBeccaService -})); - -let SearchResult: any; - -beforeEach(async () => { - vi.clearAllMocks(); - - mockBeccaService.getNoteTitleForPath.mockReturnValue('Test Note Title'); - - mockBecca.notes['test123'] = { - noteId: 'test123', - title: 'Test Note', - isInHiddenSubtree: vi.fn().mockReturnValue(false) - }; - - const module = await import('./search_result.js'); - SearchResult = module.default; -}); - -describe('SearchResult', () => { - describe('constructor', () => { - it('should initialize with note path array', () => { - const searchResult = new SearchResult(['root', 'folder', 'test123']); - - expect(searchResult.notePathArray).toEqual(['root', 'folder', 'test123']); - expect(searchResult.noteId).toBe('test123'); - expect(searchResult.notePath).toBe('root/folder/test123'); - expect(searchResult.score).toBe(0); - expect(mockBeccaService.getNoteTitleForPath).toHaveBeenCalledWith(['root', 'folder', 'test123']); + beforeEach(() => { + becca.reset(); + note = buildNote({ + id: "test123", + title: "Test Note" }); }); - describe('computeScore', () => { - let searchResult: any; + describe("constructor", () => { + it("should initialize with note path array", () => { + const searchResult = new SearchResult([note.noteId]); + + expect(searchResult.notePathArray).toEqual(["test123"]); + expect(searchResult.noteId).toBe("test123"); + expect(searchResult.notePath).toBe("test123"); + expect(searchResult.score).toBe(0); + expect(searchResult.notePathTitle).toBe("Test Note"); + }); + }); + + describe("computeScore", () => { + let searchResult: SearchResult; beforeEach(() => { - searchResult = new SearchResult(['root', 'test123']); + searchResult = new SearchResult([note.noteId]); }); - describe('basic scoring', () => { - it('should give highest score for exact note ID match', () => { - searchResult.computeScore('test123', ['test123']); + describe("basic scoring", () => { + it("should give highest score for exact note ID match", () => { + searchResult.computeScore("test123", ["test123"]); expect(searchResult.score).toBeGreaterThanOrEqual(1000); }); - it('should give high score for exact title match', () => { - searchResult.computeScore('test note', ['test', 'note']); + it("should give high score for exact title match", () => { + searchResult.computeScore("test note", ["test", "note"]); expect(searchResult.score).toBeGreaterThan(2000); }); - it('should give medium score for title prefix match', () => { - searchResult.computeScore('test', ['test']); + it("should give medium score for title prefix match", () => { + searchResult.computeScore("test", ["test"]); expect(searchResult.score).toBeGreaterThan(500); }); - it('should give lower score for title word match', () => { - mockBecca.notes['test123'].title = 'This is a test note'; - searchResult.computeScore('test', ['test']); + it("should give lower score for title word match", () => { + note.title = "This is a test note"; + searchResult.computeScore("test", ["test"]); expect(searchResult.score).toBeGreaterThan(300); }); }); - describe('hidden notes penalty', () => { - it('should apply penalty for hidden notes', () => { - mockBecca.notes['test123'].isInHiddenSubtree.mockReturnValue(true); + describe("hidden notes penalty", () => { + it("should apply penalty for hidden notes", () => { + const hiddenNote = buildNote({ + id: "_hidden", + title: "Test Note" + }); + const hiddenSearchResult = new SearchResult([hiddenNote.noteId]); - searchResult.computeScore('test', ['test']); - const hiddenScore = searchResult.score; + hiddenSearchResult.computeScore("test", ["test"]); + const hiddenScore = hiddenSearchResult.score; - mockBecca.notes['test123'].isInHiddenSubtree.mockReturnValue(false); - searchResult.score = 0; - searchResult.computeScore('test', ['test']); + searchResult.computeScore("test", ["test"]); const normalScore = searchResult.score; expect(normalScore).toBeGreaterThan(hiddenScore); @@ -95,51 +78,51 @@ describe('SearchResult', () => { }); }); - describe('addScoreForStrings', () => { - let searchResult: any; + describe("addScoreForStrings", () => { + let searchResult: SearchResult; beforeEach(() => { - searchResult = new SearchResult(['root', 'test123']); + searchResult = new SearchResult([note.noteId]); }); - it('should give highest score for exact token match', () => { - searchResult.addScoreForStrings(['sample'], 'sample text', 1.0); + it("should give highest score for exact token match", () => { + searchResult.addScoreForStrings(["sample"], "sample text", 1.0); const exactScore = searchResult.score; searchResult.score = 0; - searchResult.addScoreForStrings(['sample'], 'sampling text', 1.0); + searchResult.addScoreForStrings(["sample"], "sampling text", 1.0); const prefixScore = searchResult.score; searchResult.score = 0; - searchResult.addScoreForStrings(['sample'], 'text sample text', 1.0); + searchResult.addScoreForStrings(["sample"], "text sample text", 1.0); const partialScore = searchResult.score; expect(exactScore).toBeGreaterThan(prefixScore); expect(exactScore).toBeGreaterThanOrEqual(partialScore); }); - it('should apply factor multiplier correctly', () => { - searchResult.addScoreForStrings(['sample'], 'sample text', 2.0); + it("should apply factor multiplier correctly", () => { + searchResult.addScoreForStrings(["sample"], "sample text", 2.0); const doubleFactorScore = searchResult.score; searchResult.score = 0; - searchResult.addScoreForStrings(['sample'], 'sample text', 1.0); + searchResult.addScoreForStrings(["sample"], "sample text", 1.0); const singleFactorScore = searchResult.score; expect(doubleFactorScore).toBe(singleFactorScore * 2); }); - it('should handle multiple tokens', () => { - searchResult.addScoreForStrings(['hello', 'world'], 'hello world test', 1.0); + it("should handle multiple tokens", () => { + searchResult.addScoreForStrings(["hello", "world"], "hello world test", 1.0); expect(searchResult.score).toBeGreaterThan(0); }); - it('should be case insensitive', () => { - searchResult.addScoreForStrings(['sample'], 'sample text', 1.0); + it("should be case insensitive", () => { + searchResult.addScoreForStrings(["sample"], "sample text", 1.0); const lowerCaseScore = searchResult.score; searchResult.score = 0; - searchResult.addScoreForStrings(['sample'], 'SAMPLE text', 1.0); + searchResult.addScoreForStrings(["sample"], "SAMPLE text", 1.0); const upperCaseScore = searchResult.score; expect(upperCaseScore).toEqual(lowerCaseScore); From 86da56d35bbae8c5f2d961d8b96ebe2fb39df42c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:05:41 +0300 Subject: [PATCH 10/23] test(e2e): broken due to missing rebuild mechanism --- apps/server/src/routes/api/database.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/routes/api/database.ts b/apps/server/src/routes/api/database.ts index b73e25a22c..05beca3e67 100644 --- a/apps/server/src/routes/api/database.ts +++ b/apps/server/src/routes/api/database.ts @@ -1,6 +1,7 @@ import { BackupDatabaseNowResponse, DatabaseCheckIntegrityResponse } from "@triliumnext/commons"; -import { becca_loader, rebuildIntegrationTestDatabase as rebuildIntegrationTestDatabaseCore,ValidationError } from "@triliumnext/core"; +import { becca_loader, ValidationError } from "@triliumnext/core"; import type { Request } from "express"; +import { readFileSync } from "fs"; import anonymizationService from "../../services/anonymization.js"; import backupService from "../../services/backup.js"; @@ -30,7 +31,11 @@ function findAndFixConsistencyIssues() { } async function rebuildIntegrationTestDatabase() { - rebuildIntegrationTestDatabaseCore(); + // Reload the integration test database fixture into the in-memory SQL + // backend, then re-init schema-dependent state and the becca cache. + // Test-mode only — registered in routes.ts under the same env-var guard. + const fixtureBytes = readFileSync(require.resolve("@triliumnext/core/src/test/fixtures/document.db")); + sql.rebuildFromBuffer(fixtureBytes); sql_init.initializeDb(); becca_loader.load(); } From 515ea9661632badcec2ada304a84637a7397491a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:08:19 +0300 Subject: [PATCH 11/23] refactor(core): cleanup expected fails --- .../trilium-core/src/services/utils/index.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/trilium-core/src/services/utils/index.spec.ts b/packages/trilium-core/src/services/utils/index.spec.ts index 398cad7161..3d065cca1c 100644 --- a/packages/trilium-core/src/services/utils/index.spec.ts +++ b/packages/trilium-core/src/services/utils/index.spec.ts @@ -465,11 +465,19 @@ describe("#toMap", () => { expect(result).toBeInstanceOf(Map); expect(result.size).toBe(0); }); - it.fails("should correctly handle duplicate keys? (currently it will overwrite the entry, so returned size will be 1 instead of 2)", () => { - const testList = [ { title: "testDupeTitle", propA: "text", propB: 123 }, { title: "testDupeTitle", propA: "prop2", propB: 456 } ]; + it("collapses entries when keys collide (last write wins)", () => { + // Documents the current contract of toMap: when two entries share a + // key, the later entry overwrites the earlier one. See the TODO in + // toMap itself — if the contract ever changes to preserve duplicates + // (e.g. returning Map), update this test along with the change. + const testList = [ + { title: "testDupeTitle", propA: "text", propB: 123 }, + { title: "testDupeTitle", propA: "prop2", propB: 456 } + ]; const result = utils.toMap(testList, "title"); expect(result).toBeInstanceOf(Map); - expect(result.size).toBe(2); + expect(result.size).toBe(1); + expect(result.get("testDupeTitle")?.propA).toBe("prop2"); }); }); From 2f7c054d6405ee89938c43ae2d62123c1726f6bd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:11:21 +0300 Subject: [PATCH 12/23] test(standalone): start running tests --- apps/client-standalone/src/test_setup.ts | 102 +++++++++++++++++++++++ apps/client-standalone/vite.config.mts | 22 ++++- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 apps/client-standalone/src/test_setup.ts diff --git a/apps/client-standalone/src/test_setup.ts b/apps/client-standalone/src/test_setup.ts new file mode 100644 index 0000000000..4e81a69f35 --- /dev/null +++ b/apps/client-standalone/src/test_setup.ts @@ -0,0 +1,102 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { initializeCore } from "@triliumnext/core"; +import schemaSql from "@triliumnext/core/src/assets/schema.sql?raw"; +import { beforeAll } from "vitest"; + +import BrowserExecutionContext from "./lightweight/cls_provider.js"; +import BrowserCryptoProvider from "./lightweight/crypto_provider.js"; +import BrowserSqlProvider from "./lightweight/sql_provider.js"; +import BrowserZipProvider from "./lightweight/zip_provider.js"; + +// ============================================================================= +// SQLite WASM compatibility shims +// ============================================================================= +// The @sqlite.org/sqlite-wasm package loads its .wasm via fetch, and its +// bundled `instantiateWasm` hook overrides any user-supplied alternative. +// Two things go wrong under vitest + happy-dom: +// 1. happy-dom's `fetch()` refuses `file://` URLs. +// 2. happy-dom installs its own Response global, which Node's +// `WebAssembly.instantiateStreaming` rejects ("Received an instance of +// Response" — it wants undici's Response). +// We intercept fetch for file:// URLs ourselves and force instantiateStreaming +// to fall back to the ArrayBuffer path. +const fileFetchCache = new Map(); + +function readFileAsArrayBuffer(url: string): ArrayBuffer { + let cached = fileFetchCache.get(url); + if (!cached) { + const bytes = readFileSync(fileURLToPath(url)); + cached = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + fileFetchCache.set(url, cached); + } + return cached; +} + +const originalFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + + if (url.startsWith("file://")) { + const body = readFileAsArrayBuffer(url); + return new Response(body, { + status: 200, + headers: { "Content-Type": "application/wasm" } + }); + } + + return originalFetch(input as RequestInfo, init); +}) as typeof fetch; + +WebAssembly.instantiateStreaming = (async (source, importObject) => { + const response = await source; + const bytes = await response.arrayBuffer(); + return WebAssembly.instantiate(bytes, importObject); +}) as typeof WebAssembly.instantiateStreaming; + +// ============================================================================= +// Core initialization for standalone-flavored tests +// ============================================================================= +// Mirror what apps/client-standalone/src/local-server-worker.ts does at +// startup, but without messaging / requests / OPFS / demo archives. We just +// need core to be initialized so that pure-becca / pure-search tests can run. + +beforeAll(async () => { + const sqlProvider = new BrowserSqlProvider(); + await sqlProvider.initWasm(); + sqlProvider.loadFromMemory(); + // Apply the schema so search/becca tests that touch SQL find real tables. + sqlProvider.exec(schemaSql); + + await initializeCore({ + executionContext: new BrowserExecutionContext(), + crypto: new BrowserCryptoProvider(), + zip: new BrowserZipProvider(), + zipExportProviderFactory: ( + await import("./lightweight/zip_export_provider_factory.js") + ).standaloneZipExportProviderFactory, + // Stub translations: pure-becca tests don't need real i18n strings. + translations: async () => undefined, + platform: { + isElectron: false, + isMac: false, + isWindows: false, + crash: (msg: string) => { + throw new Error(`Platform crash: ${msg}`); + }, + getEnv: () => undefined + }, + schema: schemaSql, + dbConfig: { + provider: sqlProvider, + isReadOnly: false, + onTransactionCommit: () => {}, + onTransactionRollback: () => {} + } + }); +}); diff --git a/apps/client-standalone/vite.config.mts b/apps/client-standalone/vite.config.mts index 5f004c6047..ea92dd7855 100644 --- a/apps/client-standalone/vite.config.mts +++ b/apps/client-standalone/vite.config.mts @@ -183,7 +183,27 @@ export default defineConfig(() => ({ } }, test: { - environment: "happy-dom" + environment: "happy-dom", + setupFiles: [join(__dirname, "src/test_setup.ts")], + dir: join(__dirname), + include: [ + "src/**/*.{test,spec}.{ts,tsx}", + "../../packages/trilium-core/src/**/*.{test,spec}.{ts,tsx}" + ], + server: { + deps: { + inline: ["@sqlite.org/sqlite-wasm"] + } + }, + alias: { + // The package's `node.mjs` entry references a non-existent + // `sqlite3-node.mjs`. Force the browser-style entry which works + // under Node + happy-dom too. + "@sqlite.org/sqlite-wasm": join( + __dirname, + "../../node_modules/@sqlite.org/sqlite-wasm/index.mjs" + ) + } }, define: { "process.env.IS_PREACT": JSON.stringify("true"), From de050b3adc6d4e0ad3939d46d7d42613c776ae01 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:12:47 +0300 Subject: [PATCH 13/23] test(core): crash due to default test script --- packages/trilium-core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trilium-core/package.json b/packages/trilium-core/package.json index 39c8258e5e..d18d4ea165 100644 --- a/packages/trilium-core/package.json +++ b/packages/trilium-core/package.json @@ -4,7 +4,7 @@ "description": "", "main": "src/index.ts", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Note: Core tests are run either through apps/server or apps/client-standalone, since they have different runtime environments.\"" }, "dependencies": { "@braintree/sanitize-url": "7.1.1", From aac8c8053d4cc929155eb312734d8947c5db63e7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:13:49 +0300 Subject: [PATCH 14/23] test(standalone): use real platform provider --- apps/client-standalone/src/test_setup.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/client-standalone/src/test_setup.ts b/apps/client-standalone/src/test_setup.ts index 4e81a69f35..9e59c9552e 100644 --- a/apps/client-standalone/src/test_setup.ts +++ b/apps/client-standalone/src/test_setup.ts @@ -7,6 +7,7 @@ import { beforeAll } from "vitest"; import BrowserExecutionContext from "./lightweight/cls_provider.js"; import BrowserCryptoProvider from "./lightweight/crypto_provider.js"; +import StandalonePlatformProvider from "./lightweight/platform_provider.js"; import BrowserSqlProvider from "./lightweight/sql_provider.js"; import BrowserZipProvider from "./lightweight/zip_provider.js"; @@ -82,15 +83,7 @@ beforeAll(async () => { ).standaloneZipExportProviderFactory, // Stub translations: pure-becca tests don't need real i18n strings. translations: async () => undefined, - platform: { - isElectron: false, - isMac: false, - isWindows: false, - crash: (msg: string) => { - throw new Error(`Platform crash: ${msg}`); - }, - getEnv: () => undefined - }, + platform: new StandalonePlatformProvider(""), schema: schemaSql, dbConfig: { provider: sqlProvider, From 2c2a20b80d647a0f7dd998f124a2316115bc4903 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:29:00 +0300 Subject: [PATCH 15/23] chore(server): bypass build warning --- scripts/build-utils.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/build-utils.ts b/scripts/build-utils.ts index 23af1db90d..dab27556f8 100644 --- a/scripts/build-utils.ts +++ b/scripts/build-utils.ts @@ -54,7 +54,15 @@ export default class BuildHelper { "pdfjs-dist", "./xhr-sync-worker.js", "vite", - "tesseract.js" + "tesseract.js", + // Test fixtures referenced via require.resolve from + // integration-test-only code paths in apps/server. These + // paths are gated at runtime by TRILIUM_INTEGRATION_TEST and + // never reached in production, but esbuild can't see through + // the gate during static analysis. Marking them external + // suppresses the spurious "require.resolve not external" + // warning without affecting the bundle behavior. + "@triliumnext/core/src/test/*" ], metafile: true, splitting: false, From 21302e414241453c6fba1e794f9bfad4eb865b3d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:34:39 +0300 Subject: [PATCH 16/23] test(standalone): get most tests to pass --- .../src/lightweight/sql_provider.ts | 24 +++++++++---- apps/client-standalone/src/test_setup.ts | 35 ++++++++++++++----- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/apps/client-standalone/src/lightweight/sql_provider.ts b/apps/client-standalone/src/lightweight/sql_provider.ts index 4b22833de4..972b58db0d 100644 --- a/apps/client-standalone/src/lightweight/sql_provider.ts +++ b/apps/client-standalone/src/lightweight/sql_provider.ts @@ -435,9 +435,18 @@ export default class BrowserSqlProvider implements DatabaseProvider { loadFromBuffer(buffer: Uint8Array): void { this.ensureSqlite3(); - // SQLite WASM can deserialize a database from a byte array - const p = this.sqlite3!.wasm.allocFromTypedArray(buffer); + // SQLite WASM's allocFromTypedArray rejects Node's Buffer (and other + // non-Uint8Array typed arrays) with "expecting 8/16/32/64". Normalize + // to a plain Uint8Array view over the same memory so callers can pass + // anything readFileSync returns. + const view = buffer instanceof Uint8Array && buffer.constructor === Uint8Array + ? buffer + : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const p = this.sqlite3!.wasm.allocFromTypedArray(view); try { + // Cached statements reference the previous DB and become invalid + // once we swap connections. Drop them so callers re-prepare. + this.clearStatementCache(); this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" }); this.opfsDbPath = undefined; // Not using OPFS @@ -445,8 +454,8 @@ export default class BrowserSqlProvider implements DatabaseProvider { this.db.pointer!, "main", p, - buffer.byteLength, - buffer.byteLength, + view.byteLength, + view.byteLength, this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE | this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE ); @@ -563,8 +572,7 @@ export default class BrowserSqlProvider implements DatabaseProvider { this.db!.exec(query); } - close(): void { - // Clean up all cached statements first + private clearStatementCache(): void { for (const statement of this.statementCache.values()) { try { statement.finalize(); @@ -574,6 +582,10 @@ export default class BrowserSqlProvider implements DatabaseProvider { } } this.statementCache.clear(); + } + + close(): void { + this.clearStatementCache(); if (this.db) { this.db.close(); diff --git a/apps/client-standalone/src/test_setup.ts b/apps/client-standalone/src/test_setup.ts index 9e59c9552e..da79bcde96 100644 --- a/apps/client-standalone/src/test_setup.ts +++ b/apps/client-standalone/src/test_setup.ts @@ -1,8 +1,10 @@ +import { createRequire } from "node:module"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { initializeCore } from "@triliumnext/core"; import schemaSql from "@triliumnext/core/src/assets/schema.sql?raw"; +import serverEnTranslations from "../../server/src/assets/translations/en/server.json"; import { beforeAll } from "vitest"; import BrowserExecutionContext from "./lightweight/cls_provider.js"; @@ -63,16 +65,20 @@ WebAssembly.instantiateStreaming = (async (source, importObject) => { // ============================================================================= // Core initialization for standalone-flavored tests // ============================================================================= -// Mirror what apps/client-standalone/src/local-server-worker.ts does at -// startup, but without messaging / requests / OPFS / demo archives. We just -// need core to be initialized so that pure-becca / pure-search tests can run. +// Mirror what apps/server/spec/setup.ts does: load the pre-seeded integration +// fixture DB into an in-memory sqlite-wasm instance, then initialize core +// against it with the standalone (browser) providers. Each vitest worker gets +// a fresh copy because tests run in forks (per the default pool). + +const require = createRequire(import.meta.url); +const fixtureDb = readFileSync( + require.resolve("@triliumnext/core/src/test/fixtures/document.db") +); beforeAll(async () => { const sqlProvider = new BrowserSqlProvider(); await sqlProvider.initWasm(); - sqlProvider.loadFromMemory(); - // Apply the schema so search/becca tests that touch SQL find real tables. - sqlProvider.exec(schemaSql); + sqlProvider.loadFromBuffer(fixtureDb); await initializeCore({ executionContext: new BrowserExecutionContext(), @@ -81,8 +87,21 @@ beforeAll(async () => { zipExportProviderFactory: ( await import("./lightweight/zip_export_provider_factory.js") ).standaloneZipExportProviderFactory, - // Stub translations: pure-becca tests don't need real i18n strings. - translations: async () => undefined, + // i18next must be wired up — keyboard_actions.ts and other modules + // call `t()` and throw if translations are missing. Inline the + // en/server.json resources via vite's JSON import so we don't need a + // backend in tests. + translations: async (i18nextInstance, locale) => { + await i18nextInstance.init({ + lng: locale, + fallbackLng: "en", + ns: "server", + defaultNS: "server", + resources: { + en: { server: serverEnTranslations } + } + }); + }, platform: new StandalonePlatformProvider(""), schema: schemaSql, dbConfig: { From 746367411cf39242427b700f7385f675ab0218c4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:36:59 +0300 Subject: [PATCH 17/23] fix(standalone): zip fix wasn't integrated --- .../src/lightweight/zip_provider.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/client-standalone/src/lightweight/zip_provider.ts b/apps/client-standalone/src/lightweight/zip_provider.ts index 5827ebcdae..f5e435c5c5 100644 --- a/apps/client-standalone/src/lightweight/zip_provider.ts +++ b/apps/client-standalone/src/lightweight/zip_provider.ts @@ -65,7 +65,7 @@ export default class BrowserZipProvider implements ZipProvider { try { for (const [fileName, data] of Object.entries(files)) { await processEntry( - { fileName }, + { fileName: decodeZipFileName(fileName) }, () => Promise.resolve(data) ); } @@ -77,3 +77,25 @@ export default class BrowserZipProvider implements ZipProvider { }); } } + +const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +/** + * fflate decodes ZIP entry filenames as CP437/Latin-1 unless the language + * encoding flag (general purpose bit 11) is set, but many real-world archives + * (e.g. those produced by macOS / Linux unzip / Python's zipfile) write UTF-8 + * filenames without setting that flag. Recover the original UTF-8 bytes from + * fflate's per-byte string and re-decode them; if the result isn't valid + * UTF-8 we fall back to the as-decoded name. + */ +function decodeZipFileName(name: string): string { + const bytes = new Uint8Array(name.length); + for (let i = 0; i < name.length; i++) { + bytes[i] = name.charCodeAt(i) & 0xff; + } + try { + return utf8Decoder.decode(bytes); + } catch { + return name; + } +} From c83531a3f129d21d80c6e9e498cd69e8f9313252 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:46:09 +0300 Subject: [PATCH 18/23] e2e(server): bring back loading of the integration test database --- apps/server/src/main.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index f452616937..4f37a77e66 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -24,7 +24,18 @@ async function startApplication() { const { DOCUMENT_PATH } = (await import("./services/data_dir.js")).default; const dbProvider = new BetterSqlite3Provider(); - dbProvider.loadFromFile(DOCUMENT_PATH, config.General.readOnly); + if (process.env.TRILIUM_INTEGRATION_TEST === "memory") { + // Integration test mode: load the same fixture buffer used by the + // unit test setup so e2e tests get a known-good starting state + // (schema + demo content + known password) without touching disk. + // The fixture path is marked external in the esbuild config, so + // require.resolve here doesn't trigger a build-time warning. + dbProvider.loadFromBuffer(fs.readFileSync( + require.resolve("@triliumnext/core/src/test/fixtures/document.db") + )); + } else { + dbProvider.loadFromFile(DOCUMENT_PATH, config.General.readOnly); + } await initializeCore({ dbConfig: { From f3f1ce5052fc6f2ee61735684f127cdde55f8ee8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 9 Apr 2026 18:48:10 +0300 Subject: [PATCH 19/23] test(standalone): happy-dom issue with Markdown import --- apps/client-standalone/src/test_setup.ts | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/client-standalone/src/test_setup.ts b/apps/client-standalone/src/test_setup.ts index da79bcde96..cd77c0a416 100644 --- a/apps/client-standalone/src/test_setup.ts +++ b/apps/client-standalone/src/test_setup.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { initializeCore } from "@triliumnext/core"; import schemaSql from "@triliumnext/core/src/assets/schema.sql?raw"; +import HappyDomHtmlParser from "happy-dom/lib/html-parser/HTMLParser.js"; import serverEnTranslations from "../../server/src/assets/translations/en/server.json"; import { beforeAll } from "vitest"; @@ -62,6 +63,31 @@ WebAssembly.instantiateStreaming = (async (source, importObject) => { return WebAssembly.instantiate(bytes, importObject); }) as typeof WebAssembly.instantiateStreaming; +// ============================================================================= +// happy-dom HTMLParser spec compliance patch +// ============================================================================= +// Per HTML5 parsing spec, a single U+000A LINE FEED immediately after a
,
+// , or