From 417228ebde55d85bef0573abc46b35bc433776ac Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 11:32:52 +0300 Subject: [PATCH 01/16] e2e: make tests reusable for standalone --- .github/copilot-instructions.md | 2 +- .github/workflows/main-docker.yml | 2 +- .github/workflows/playwright.yml | 4 +- CLAUDE.md | 4 +- apps/client-standalone/package.json | 3 +- apps/client-standalone/playwright.config.ts | 16 +++++ apps/server-e2e/playwright.config.ts | 44 ------------- .../src => server/e2e}/exact_search.spec.ts | 4 +- .../src => server/e2e}/shared_notes.spec.ts | 2 +- apps/server/package.json | 1 + apps/server/playwright.config.ts | 22 +++++++ .../Developer Guide/Testing.md | 10 +-- .../Testing/End-to-end tests.md | 7 ++- eslint.config.mjs | 4 +- flake.nix | 2 +- .../trilium-e2e}/package.json | 6 +- packages/trilium-e2e/src/base-config.ts | 63 +++++++++++++++++++ .../trilium-e2e}/src/duplicate.spec.ts | 2 +- .../trilium-e2e}/src/help.spec.ts | 0 .../trilium-e2e}/src/i18n.spec.ts | 0 .../src/layout/open_note_and_activate.spec.ts | 0 .../src/layout/split_pane.spec.ts | 0 .../trilium-e2e}/src/layout/tab_bar.spec.ts | 0 .../trilium-e2e}/src/layout/tree.spec.ts | 0 .../trilium-e2e}/src/note_types/code.spec.ts | 0 .../src/note_types/mermaid.spec.ts | 0 .../src/note_types/mindmap.spec.ts | 0 .../src/note_types/note_map.spec.ts | 0 .../trilium-e2e}/src/note_types/pdf.spec.ts | 0 .../trilium-e2e}/src/note_types/text.spec.ts | 0 .../trilium-e2e}/src/settings.spec.ts | 6 +- .../trilium-e2e}/src/support/app.ts | 9 ++- .../trilium-e2e}/src/tree.spec.ts | 0 .../trilium-e2e}/tsconfig.json | 0 pnpm-lock.yaml | 12 ++-- tsconfig.json | 2 +- 36 files changed, 148 insertions(+), 79 deletions(-) create mode 100644 apps/client-standalone/playwright.config.ts delete mode 100644 apps/server-e2e/playwright.config.ts rename apps/{server-e2e/src => server/e2e}/exact_search.spec.ts (99%) rename apps/{server-e2e/src => server/e2e}/shared_notes.spec.ts (84%) create mode 100644 apps/server/playwright.config.ts rename {apps/server-e2e => packages/trilium-e2e}/package.json (52%) create mode 100644 packages/trilium-e2e/src/base-config.ts rename {apps/server-e2e => packages/trilium-e2e}/src/duplicate.spec.ts (89%) rename {apps/server-e2e => packages/trilium-e2e}/src/help.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/i18n.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/layout/open_note_and_activate.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/layout/split_pane.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/layout/tab_bar.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/layout/tree.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/note_types/code.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/note_types/mermaid.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/note_types/mindmap.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/note_types/note_map.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/note_types/pdf.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/note_types/text.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/src/settings.spec.ts (79%) rename {apps/server-e2e => packages/trilium-e2e}/src/support/app.ts (95%) rename {apps/server-e2e => packages/trilium-e2e}/src/tree.spec.ts (100%) rename {apps/server-e2e => packages/trilium-e2e}/tsconfig.json (100%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fad12709d3..06c5df5481 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -154,7 +154,7 @@ pnpm desktop:build # Build desktop application ### Test Organization - **Server tests** (`apps/server/spec/`): Must run sequentially (shared database state) - **Client tests** (`apps/client/src/`): Can run in parallel -- **E2E tests** (`apps/server-e2e/`): Use Playwright for integration testing +- **E2E tests** (`packages/trilium-e2e/`): Shared Playwright tests, run via `pnpm --filter server e2e` or `pnpm --filter client-standalone e2e` - **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests **Pattern**: When adding new API endpoints, add tests in `spec/etapi/` following existing patterns (see `search.spec.ts`). diff --git a/.github/workflows/main-docker.yml b/.github/workflows/main-docker.yml index eecda5ec1a..3cbc4c1ee9 100644 --- a/.github/workflows/main-docker.yml +++ b/.github/workflows/main-docker.yml @@ -82,7 +82,7 @@ jobs: require-healthy: true - name: Run Playwright tests - run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm --filter=server-e2e e2e + run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm --filter=server e2e - name: Upload Playwright trace if: failure() diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index a51d29e652..caf1d5762c 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -73,14 +73,14 @@ jobs: sleep 10 - name: Server end-to-end tests - run: pnpm --filter server-e2e e2e + run: pnpm --filter server e2e - name: Upload test report if: failure() uses: actions/upload-artifact@v7 with: name: e2e report ${{ matrix.arch }} - path: apps/server-e2e/test-output + path: apps/server/test-output - name: Kill the server if: always() diff --git a/CLAUDE.md b/CLAUDE.md index a9050ad089..2387fe1971 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,6 @@ apps/ desktop/ # Electron (bundles server + client) client-standalone/ # Standalone client (WASM + service workers, no Node.js) standalone-desktop/ # Standalone desktop variant - server-e2e/ # Playwright E2E tests for server web-clipper/ # Browser extension website/ # Project website db-compare/, dump-db/, edit-docs/, build-docs/, icon-pack-builder/ @@ -67,6 +66,7 @@ apps/ packages/ trilium-core/ # Core business logic: entities, services, SQL, sync commons/ # Shared interfaces and utilities + trilium-e2e/ # Shared Playwright E2E tests ckeditor5/ # Custom rich text editor bundle codemirror/ # Code editor integration highlightjs/ # Syntax highlighting @@ -248,7 +248,7 @@ Use `note.getOwnedAttribute()` for direct, `note.getAttribute()` for inherited. - **Server tests** (`apps/server/spec/`): Vitest, must run sequentially (shared DB), forks pool, max 6 workers - **Client tests** (`apps/client/src/`): Vitest with happy-dom environment, can run in parallel -- **E2E tests** (`apps/server-e2e/`): Playwright, Chromium, server started automatically on port 8082 +- **E2E tests** (`packages/trilium-e2e/`): Shared Playwright tests, run via `pnpm --filter server e2e` or `pnpm --filter client-standalone e2e` - **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests ## Documentation diff --git a/apps/client-standalone/package.json b/apps/client-standalone/package.json index 420b7a99fb..c1c3e0023b 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -9,7 +9,8 @@ "dev": "vite dev", "test": "vitest", "start-prod": "pnpm build && pnpm vite preview --port 8888", - "coverage": "vitest --coverage" + "coverage": "vitest --coverage", + "e2e": "pnpm build && playwright test" }, "dependencies": { "@excalidraw/excalidraw": "0.18.0", diff --git a/apps/client-standalone/playwright.config.ts b/apps/client-standalone/playwright.config.ts new file mode 100644 index 0000000000..15f0147134 --- /dev/null +++ b/apps/client-standalone/playwright.config.ts @@ -0,0 +1,16 @@ +import { createBaseConfig } from "../../packages/trilium-e2e/src/base-config"; + +const port = process.env["TRILIUM_PORT"] ?? "8082"; +const baseURL = process.env["BASE_URL"] || `http://127.0.0.1:${port}`; + +export default createBaseConfig({ + appDir: __dirname, + projectName: "standalone", + webServer: !process.env.TRILIUM_DOCKER ? { + command: "pnpm vite preview --port " + port, + url: baseURL, + reuseExistingServer: !process.env.CI, + cwd: __dirname, + timeout: 5 * 60 * 1000 + } : undefined, +}); diff --git a/apps/server-e2e/playwright.config.ts b/apps/server-e2e/playwright.config.ts deleted file mode 100644 index d4fe1ecd5c..0000000000 --- a/apps/server-e2e/playwright.config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; -import { join } from 'path'; - -// For CI, you may want to set BASE_URL to the deployed application. -const port = process.env['TRILIUM_PORT'] ?? "8082"; -const baseURL = process.env['BASE_URL'] || `http://127.0.0.1:${port}`; - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: "src", - reporter: [["list"], ["html", { outputFolder: "test-output" }]], - outputDir: "test-output", - retries: 3, - - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - baseURL, - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, - - /* Run your local dev server before starting the tests */ - webServer: !process.env.TRILIUM_DOCKER ? { - command: 'pnpm start-prod-no-dir', - url: baseURL, - reuseExistingServer: !process.env.CI, - cwd: join(__dirname, "../server"), - env: { - TRILIUM_DATA_DIR: "spec/db", - TRILIUM_PORT: port, - TRILIUM_INTEGRATION_TEST: "memory" - }, - timeout: 5 * 60 * 1000 - } : undefined, - - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - } - ] -}); diff --git a/apps/server-e2e/src/exact_search.spec.ts b/apps/server/e2e/exact_search.spec.ts similarity index 99% rename from apps/server-e2e/src/exact_search.spec.ts rename to apps/server/e2e/exact_search.spec.ts index 1e4660e41f..f5c43d9da5 100644 --- a/apps/server-e2e/src/exact_search.spec.ts +++ b/apps/server/e2e/exact_search.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; -import App from "./support/app"; +import App, { getBaseUrl } from "../../../packages/trilium-e2e/src/support/app"; -const BASE_URL = "http://127.0.0.1:8082"; +const BASE_URL = getBaseUrl(); /** * E2E tests for exact search functionality using the leading "=" operator. diff --git a/apps/server-e2e/src/shared_notes.spec.ts b/apps/server/e2e/shared_notes.spec.ts similarity index 84% rename from apps/server-e2e/src/shared_notes.spec.ts rename to apps/server/e2e/shared_notes.spec.ts index a3ca3e0e8e..4252515c5b 100644 --- a/apps/server-e2e/src/shared_notes.spec.ts +++ b/apps/server/e2e/shared_notes.spec.ts @@ -1,5 +1,5 @@ import { test, expect, Page } from "@playwright/test"; -import App from "./support/app"; +import App from "../../../packages/trilium-e2e/src/support/app"; test("Goes to share root", async ({ page, context }) => { const app = new App(page, context); diff --git a/apps/server/package.json b/apps/server/package.json index e909296494..2e6563a863 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ "test-build": "vitest --config vitest.build.config.mts", "start-prod": "cross-env TRILIUM_DATA_DIR=data pnpm start-prod-no-dir", "start-prod-no-dir": "pnpm build && cross-env TRILIUM_ENV=production TRILIUM_PORT=8082 node dist/main.cjs", + "e2e": "playwright test", "circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular", "docker-build-debian": "pnpm build && docker build . -t triliumnext-debian -f Dockerfile", "docker-build-alpine": "pnpm build && docker build . -t triliumnext-alpine -f Dockerfile.alpine", diff --git a/apps/server/playwright.config.ts b/apps/server/playwright.config.ts new file mode 100644 index 0000000000..f3a73101e1 --- /dev/null +++ b/apps/server/playwright.config.ts @@ -0,0 +1,22 @@ +import { createBaseConfig } from "../../packages/trilium-e2e/src/base-config"; + +const port = process.env["TRILIUM_PORT"] ?? "8082"; +const baseURL = process.env["BASE_URL"] || `http://127.0.0.1:${port}`; + +export default createBaseConfig({ + appDir: __dirname, + localTestDir: "e2e", + projectName: "server", + webServer: !process.env.TRILIUM_DOCKER ? { + command: "pnpm start-prod-no-dir", + url: baseURL, + reuseExistingServer: !process.env.CI, + cwd: __dirname, + env: { + TRILIUM_DATA_DIR: "spec/db", + TRILIUM_PORT: port, + TRILIUM_INTEGRATION_TEST: "memory" + }, + timeout: 5 * 60 * 1000 + } : undefined, +}); diff --git a/docs/Developer Guide/Developer Guide/Testing.md b/docs/Developer Guide/Developer Guide/Testing.md index 570f52ded2..a8d836f81a 100644 --- a/docs/Developer Guide/Developer Guide/Testing.md +++ b/docs/Developer Guide/Developer Guide/Testing.md @@ -26,11 +26,13 @@ apps/ │ └── src/**/*.spec.ts # Server tests ├── client/ │ └── src/**/*.spec.ts # Client tests -└── server-e2e/ -│ └── tests/**/*.spec.ts # E2E tests +├── server/ +│ └── e2e/**/*.spec.ts # Server-specific E2E tests └── desktop/ - └── e2e - └── tests/**/*.spec.ts # E2E tests + └── e2e/**/*.spec.ts # Desktop E2E tests +packages/ +└── trilium-e2e/ + └── src/**/*.spec.ts # Shared E2E tests ``` ## Running tests diff --git a/docs/Developer Guide/Developer Guide/Testing/End-to-end tests.md b/docs/Developer Guide/Developer Guide/Testing/End-to-end tests.md index 60060adaee..ca10fd4cde 100644 --- a/docs/Developer Guide/Developer Guide/Testing/End-to-end tests.md +++ b/docs/Developer Guide/Developer Guide/Testing/End-to-end tests.md @@ -9,7 +9,12 @@ * Playwright with Electron * Tests some basic functionality such as creating a new document. -These can be found in `apps/server-e2e` and `apps/desktop/e2e`. +Shared E2E tests live in `packages/trilium-e2e/`. Server-specific tests are in `apps/server/e2e/`, desktop tests in `apps/desktop/e2e/`. + +Run E2E tests via: +- `pnpm --filter server e2e` (server) +- `pnpm --filter client-standalone e2e` (standalone) +- `pnpm --filter desktop e2e` (desktop/Electron) ## First-time run diff --git a/eslint.config.mjs b/eslint.config.mjs index 8d9119044c..eb097e3851 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -81,7 +81,9 @@ const mainConfig = [ const playwrightConfig = { files: [ - "apps/server-e2e/src/**/*.spec.ts", + "packages/trilium-e2e/src/**/*.spec.ts", + "apps/server/e2e/**/*.spec.ts", + "apps/client-standalone/e2e/**/*.spec.ts", "apps/desktop/e2e/**/*.spec.ts" ], plugins: { playwright }, diff --git a/flake.nix b/flake.nix index 55f299b854..aed66e254a 100644 --- a/flake.nix +++ b/flake.nix @@ -177,7 +177,7 @@ "apps/dump-db" "apps/edit-docs" "apps/server" - "apps/server-e2e" + "packages/trilium-e2e" ]; desktopItems = lib.optionals (app == "desktop") [ diff --git a/apps/server-e2e/package.json b/packages/trilium-e2e/package.json similarity index 52% rename from apps/server-e2e/package.json rename to packages/trilium-e2e/package.json index f1a195f360..db10fca3fd 100644 --- a/apps/server-e2e/package.json +++ b/packages/trilium-e2e/package.json @@ -1,10 +1,8 @@ { - "name": "@triliumnext/server-e2e", + "name": "@triliumnext/trilium-e2e", "version": "0.0.1", "private": true, - "scripts": { - "e2e": "playwright test" - }, + "scripts": {}, "devDependencies": { "dotenv": "17.4.1" } diff --git a/packages/trilium-e2e/src/base-config.ts b/packages/trilium-e2e/src/base-config.ts new file mode 100644 index 0000000000..450fc1de12 --- /dev/null +++ b/packages/trilium-e2e/src/base-config.ts @@ -0,0 +1,63 @@ +import { defineConfig, devices, type PlaywrightTestConfig } from "@playwright/test"; +import { join } from "path"; + +interface BaseConfigOptions { + /** + * The directory of the calling app (i.e. `__dirname` from the app's playwright.config.ts). + */ + appDir: string; + + /** + * Optional local test directory for app-specific tests (relative to appDir). + * If provided, a second project is added for app-specific tests. + */ + localTestDir?: string; + + /** + * The name for the app-specific test project (e.g. "server", "standalone"). + */ + projectName: string; + + /** + * Optional webServer configuration to start the app before tests. + */ + webServer?: PlaywrightTestConfig["webServer"]; +} + +/** + * Creates a base Playwright configuration that includes the shared trilium-e2e + * tests and optionally app-specific tests. + */ +export function createBaseConfig({ appDir, localTestDir, projectName, webServer }: BaseConfigOptions) { + const port = process.env["TRILIUM_PORT"] ?? "8082"; + const baseURL = process.env["BASE_URL"] || `http://127.0.0.1:${port}`; + const sharedTestDir = join(__dirname); + + const projects: PlaywrightTestConfig["projects"] = [ + { + name: `${projectName}-shared`, + testDir: sharedTestDir, + use: { ...devices["Desktop Chrome"] }, + } + ]; + + if (localTestDir) { + projects.push({ + name: projectName, + testDir: join(appDir, localTestDir), + use: { ...devices["Desktop Chrome"] }, + }); + } + + return defineConfig({ + reporter: [["list"], ["html", { outputFolder: join(appDir, "test-output") }]], + outputDir: join(appDir, "test-output"), + retries: 3, + use: { + baseURL, + trace: "on-first-retry", + }, + webServer, + projects, + }); +} diff --git a/apps/server-e2e/src/duplicate.spec.ts b/packages/trilium-e2e/src/duplicate.spec.ts similarity index 89% rename from apps/server-e2e/src/duplicate.spec.ts rename to packages/trilium-e2e/src/duplicate.spec.ts index b7ca06aacd..3705974b4b 100644 --- a/apps/server-e2e/src/duplicate.spec.ts +++ b/packages/trilium-e2e/src/duplicate.spec.ts @@ -4,7 +4,7 @@ import App from "./support/app"; test("Can duplicate note with broken links", async ({ page, context }) => { const app = new App(page, context); await app.goto({ - url: "http://localhost:8082/#root/Q5abPvymDH6C/2VammGGdG6Ie" + url: "/#root/Q5abPvymDH6C/2VammGGdG6Ie" }); await app.noteTree.getByText("Note map").first().click({ button: "right" }); diff --git a/apps/server-e2e/src/help.spec.ts b/packages/trilium-e2e/src/help.spec.ts similarity index 100% rename from apps/server-e2e/src/help.spec.ts rename to packages/trilium-e2e/src/help.spec.ts diff --git a/apps/server-e2e/src/i18n.spec.ts b/packages/trilium-e2e/src/i18n.spec.ts similarity index 100% rename from apps/server-e2e/src/i18n.spec.ts rename to packages/trilium-e2e/src/i18n.spec.ts diff --git a/apps/server-e2e/src/layout/open_note_and_activate.spec.ts b/packages/trilium-e2e/src/layout/open_note_and_activate.spec.ts similarity index 100% rename from apps/server-e2e/src/layout/open_note_and_activate.spec.ts rename to packages/trilium-e2e/src/layout/open_note_and_activate.spec.ts diff --git a/apps/server-e2e/src/layout/split_pane.spec.ts b/packages/trilium-e2e/src/layout/split_pane.spec.ts similarity index 100% rename from apps/server-e2e/src/layout/split_pane.spec.ts rename to packages/trilium-e2e/src/layout/split_pane.spec.ts diff --git a/apps/server-e2e/src/layout/tab_bar.spec.ts b/packages/trilium-e2e/src/layout/tab_bar.spec.ts similarity index 100% rename from apps/server-e2e/src/layout/tab_bar.spec.ts rename to packages/trilium-e2e/src/layout/tab_bar.spec.ts diff --git a/apps/server-e2e/src/layout/tree.spec.ts b/packages/trilium-e2e/src/layout/tree.spec.ts similarity index 100% rename from apps/server-e2e/src/layout/tree.spec.ts rename to packages/trilium-e2e/src/layout/tree.spec.ts diff --git a/apps/server-e2e/src/note_types/code.spec.ts b/packages/trilium-e2e/src/note_types/code.spec.ts similarity index 100% rename from apps/server-e2e/src/note_types/code.spec.ts rename to packages/trilium-e2e/src/note_types/code.spec.ts diff --git a/apps/server-e2e/src/note_types/mermaid.spec.ts b/packages/trilium-e2e/src/note_types/mermaid.spec.ts similarity index 100% rename from apps/server-e2e/src/note_types/mermaid.spec.ts rename to packages/trilium-e2e/src/note_types/mermaid.spec.ts diff --git a/apps/server-e2e/src/note_types/mindmap.spec.ts b/packages/trilium-e2e/src/note_types/mindmap.spec.ts similarity index 100% rename from apps/server-e2e/src/note_types/mindmap.spec.ts rename to packages/trilium-e2e/src/note_types/mindmap.spec.ts diff --git a/apps/server-e2e/src/note_types/note_map.spec.ts b/packages/trilium-e2e/src/note_types/note_map.spec.ts similarity index 100% rename from apps/server-e2e/src/note_types/note_map.spec.ts rename to packages/trilium-e2e/src/note_types/note_map.spec.ts diff --git a/apps/server-e2e/src/note_types/pdf.spec.ts b/packages/trilium-e2e/src/note_types/pdf.spec.ts similarity index 100% rename from apps/server-e2e/src/note_types/pdf.spec.ts rename to packages/trilium-e2e/src/note_types/pdf.spec.ts diff --git a/apps/server-e2e/src/note_types/text.spec.ts b/packages/trilium-e2e/src/note_types/text.spec.ts similarity index 100% rename from apps/server-e2e/src/note_types/text.spec.ts rename to packages/trilium-e2e/src/note_types/text.spec.ts diff --git a/apps/server-e2e/src/settings.spec.ts b/packages/trilium-e2e/src/settings.spec.ts similarity index 79% rename from apps/server-e2e/src/settings.spec.ts rename to packages/trilium-e2e/src/settings.spec.ts index d0216c2550..e59d6a8c7a 100644 --- a/apps/server-e2e/src/settings.spec.ts +++ b/packages/trilium-e2e/src/settings.spec.ts @@ -4,21 +4,21 @@ import App from "./support/app"; test("Native Title Bar not displayed on web", async ({ page, context }) => { const app = new App(page, context); - await app.goto({ url: "http://localhost:8082/#root/_hidden/_options/_optionsAppearance" }); + await app.goto({ url: "/#root/_hidden/_options/_optionsAppearance" }); await expect(app.currentNoteSplitContent.getByRole("heading", { name: "Theme" })).toBeVisible(); await expect(app.currentNoteSplitContent.getByRole("heading", { name: "Native Title Bar (requires" })).toBeHidden(); }); test("Tray settings not displayed on web", async ({ page, context }) => { const app = new App(page, context); - await app.goto({ url: "http://localhost:8082/#root/_hidden/_options/_optionsOther" }); + await app.goto({ url: "/#root/_hidden/_options/_optionsOther" }); await expect(app.currentNoteSplitContent.getByRole("heading", { name: "Note Erasure Timeout" })).toBeVisible(); await expect(app.currentNoteSplitContent.getByRole("heading", { name: "Tray" })).toBeHidden(); }); test("Spellcheck settings not displayed on web", async ({ page, context }) => { const app = new App(page, context); - await app.goto({ url: "http://localhost:8082/#root/_hidden/_options/_optionsSpellcheck" }); + await app.goto({ url: "/#root/_hidden/_options/_optionsSpellcheck" }); await expect(app.currentNoteSplitContent.getByText("These options apply only for desktop builds")).toBeVisible(); await expect(app.currentNoteSplitContent.getByText("Check spelling")).toBeHidden(); }); diff --git a/apps/server-e2e/src/support/app.ts b/packages/trilium-e2e/src/support/app.ts similarity index 95% rename from apps/server-e2e/src/support/app.ts rename to packages/trilium-e2e/src/support/app.ts index 3922019192..665741cee9 100644 --- a/apps/server-e2e/src/support/app.ts +++ b/packages/trilium-e2e/src/support/app.ts @@ -7,7 +7,10 @@ interface GotoOpts { preserveTabs?: boolean; } -const BASE_URL = "http://127.0.0.1:8082"; +export function getBaseUrl(): string { + const port = process.env["TRILIUM_PORT"] ?? "8082"; + return process.env["BASE_URL"] || `http://127.0.0.1:${port}`; +} interface DropdownLocator extends Locator { selectOptionByText: (text: string) => Promise; @@ -48,7 +51,7 @@ export default class App { await this.context.addCookies([ { - url: BASE_URL, + url: getBaseUrl(), name: "trilium-device", value: isMobile ? "mobile" : "desktop" } @@ -165,7 +168,7 @@ export default class App { expect(csrfToken).toBeTruthy(); await expect( - await this.page.request.put(`${BASE_URL}/api/options/${key}/${value}`, { + await this.page.request.put(`${getBaseUrl()}/api/options/${key}/${value}`, { headers: { "x-csrf-token": csrfToken } diff --git a/apps/server-e2e/src/tree.spec.ts b/packages/trilium-e2e/src/tree.spec.ts similarity index 100% rename from apps/server-e2e/src/tree.spec.ts rename to packages/trilium-e2e/src/tree.spec.ts diff --git a/apps/server-e2e/tsconfig.json b/packages/trilium-e2e/tsconfig.json similarity index 100% rename from apps/server-e2e/tsconfig.json rename to packages/trilium-e2e/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0ae46e2b..801f4be354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1070,12 +1070,6 @@ importers: specifier: 3.3.0 version: 3.3.0 - apps/server-e2e: - devDependencies: - dotenv: - specifier: 17.4.1 - version: 17.4.1 - apps/web-clipper: dependencies: cash-dom: @@ -1702,6 +1696,12 @@ importers: specifier: 2.16.1 version: 2.16.1 + packages/trilium-e2e: + devDependencies: + dotenv: + specifier: 17.4.1 + version: 17.4.1 + packages/turndown-plugin-gfm: devDependencies: happy-dom: diff --git a/tsconfig.json b/tsconfig.json index 38d1ed1cba..568582616b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "path": "./apps/server" }, { - "path": "./apps/server-e2e" + "path": "./packages/trilium-e2e" }, { "path": "./apps/client" From 2a9976cfbbc51f808ef4545496dc22fb01004a7e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 11:39:39 +0300 Subject: [PATCH 02/16] feat(ci): run e2e for standalone --- .github/workflows/playwright.yml | 41 +++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index caf1d5762c..e28c6f0a48 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -14,7 +14,7 @@ permissions: contents: read jobs: - e2e: + e2e-server: strategy: fail-fast: false matrix: @@ -85,3 +85,42 @@ jobs: - name: Kill the server if: always() run: pkill -f trilium || true + + e2e-standalone: + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + os: ubuntu-22.04 + - name: linux-arm64 + os: ubuntu-24.04-arm + runs-on: ${{ matrix.os }} + name: Standalone E2E tests on ${{ matrix.name }} + steps: + - uses: actions/checkout@v6 + with: + filter: tree:0 + fetch-depth: 0 + + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + run: pnpm exec playwright install --with-deps + + - name: Standalone end-to-end tests + run: pnpm --filter client-standalone e2e + + - name: Upload test report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: standalone e2e report ${{ matrix.name }} + path: apps/client-standalone/test-output From 3a3f49e21a6f55f0d847ddfe05cd2871c5e7d215 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 11:48:35 +0300 Subject: [PATCH 03/16] feat(ci): run standalone tests with one worker --- apps/client-standalone/playwright.config.ts | 1 + packages/trilium-e2e/src/base-config.ts | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/client-standalone/playwright.config.ts b/apps/client-standalone/playwright.config.ts index 15f0147134..448e32d943 100644 --- a/apps/client-standalone/playwright.config.ts +++ b/apps/client-standalone/playwright.config.ts @@ -6,6 +6,7 @@ const baseURL = process.env["BASE_URL"] || `http://127.0.0.1:${port}`; export default createBaseConfig({ appDir: __dirname, projectName: "standalone", + workers: 1, webServer: !process.env.TRILIUM_DOCKER ? { command: "pnpm vite preview --port " + port, url: baseURL, diff --git a/packages/trilium-e2e/src/base-config.ts b/packages/trilium-e2e/src/base-config.ts index 450fc1de12..321916dff8 100644 --- a/packages/trilium-e2e/src/base-config.ts +++ b/packages/trilium-e2e/src/base-config.ts @@ -22,13 +22,18 @@ interface BaseConfigOptions { * Optional webServer configuration to start the app before tests. */ webServer?: PlaywrightTestConfig["webServer"]; + + /** + * Number of parallel workers. Defaults to Playwright's default (half of CPU cores). + */ + workers?: number; } /** * Creates a base Playwright configuration that includes the shared trilium-e2e * tests and optionally app-specific tests. */ -export function createBaseConfig({ appDir, localTestDir, projectName, webServer }: BaseConfigOptions) { +export function createBaseConfig({ appDir, localTestDir, projectName, webServer, workers }: BaseConfigOptions) { const port = process.env["TRILIUM_PORT"] ?? "8082"; const baseURL = process.env["BASE_URL"] || `http://127.0.0.1:${port}`; const sharedTestDir = join(__dirname); @@ -57,6 +62,7 @@ export function createBaseConfig({ appDir, localTestDir, projectName, webServer baseURL, trace: "on-first-retry", }, + workers, webServer, projects, }); From c995c15eaee3556f3855db399a686cffbd05bc37 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 11:53:22 +0300 Subject: [PATCH 04/16] fix(ci): standalone timing out during build --- .github/workflows/playwright.yml | 12 ++++++++++++ apps/client-standalone/package.json | 3 ++- apps/client-standalone/playwright.config.ts | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e28c6f0a48..8e25eea8d2 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -97,6 +97,9 @@ jobs: os: ubuntu-24.04-arm runs-on: ${{ matrix.os }} name: Standalone E2E tests on ${{ matrix.name }} + env: + TRILIUM_DOCKER: 1 + TRILIUM_PORT: 8082 steps: - uses: actions/checkout@v6 with: @@ -115,6 +118,15 @@ jobs: - name: Install Playwright browsers run: pnpm exec playwright install --with-deps + - name: Build standalone + run: pnpm --filter client-standalone build + + - name: Start standalone preview server + run: | + cd apps/client-standalone + pnpm vite preview --port $TRILIUM_PORT & + sleep 5 + - name: Standalone end-to-end tests run: pnpm --filter client-standalone e2e diff --git a/apps/client-standalone/package.json b/apps/client-standalone/package.json index c1c3e0023b..e8d335b0a5 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -10,7 +10,8 @@ "test": "vitest", "start-prod": "pnpm build && pnpm vite preview --port 8888", "coverage": "vitest --coverage", - "e2e": "pnpm build && playwright test" + "e2e": "playwright test", + "start-prod-no-dir": "pnpm build && pnpm vite preview --port ${TRILIUM_PORT:-8082}" }, "dependencies": { "@excalidraw/excalidraw": "0.18.0", diff --git a/apps/client-standalone/playwright.config.ts b/apps/client-standalone/playwright.config.ts index 448e32d943..196757bfb7 100644 --- a/apps/client-standalone/playwright.config.ts +++ b/apps/client-standalone/playwright.config.ts @@ -8,7 +8,7 @@ export default createBaseConfig({ projectName: "standalone", workers: 1, webServer: !process.env.TRILIUM_DOCKER ? { - command: "pnpm vite preview --port " + port, + command: "pnpm start-prod-no-dir", url: baseURL, reuseExistingServer: !process.env.CI, cwd: __dirname, From 0f68993605bf287dfaa1f8c051c2004e1b043be7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 12:03:59 +0300 Subject: [PATCH 05/16] fix(ci): wrong host for standalone --- .github/workflows/playwright.yml | 2 +- apps/client-standalone/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 8e25eea8d2..e27b6bc3f7 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -124,7 +124,7 @@ jobs: - name: Start standalone preview server run: | cd apps/client-standalone - pnpm vite preview --port $TRILIUM_PORT & + pnpm vite preview --port $TRILIUM_PORT --host 127.0.0.1 & sleep 5 - name: Standalone end-to-end tests diff --git a/apps/client-standalone/package.json b/apps/client-standalone/package.json index e8d335b0a5..02c6811d48 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -11,7 +11,7 @@ "start-prod": "pnpm build && pnpm vite preview --port 8888", "coverage": "vitest --coverage", "e2e": "playwright test", - "start-prod-no-dir": "pnpm build && pnpm vite preview --port ${TRILIUM_PORT:-8082}" + "start-prod-no-dir": "pnpm build && pnpm vite preview --port ${TRILIUM_PORT:-8082} --host 127.0.0.1" }, "dependencies": { "@excalidraw/excalidraw": "0.18.0", From a43cecb0f02946e26f133cbe05f36b3ea06a71cc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 12:36:28 +0300 Subject: [PATCH 06/16] e2e(standalone): integration test database not loaded --- .github/workflows/playwright.yml | 2 +- apps/client-standalone/package.json | 2 +- apps/client-standalone/playwright.config.ts | 3 ++ .../src/lightweight/platform_provider.ts | 1 + .../src/local-server-worker.ts | 47 +++++++++++++------ apps/client-standalone/vite.config.mts | 21 +++++++++ packages/trilium-e2e/src/support/app.ts | 21 ++++++++- 7 files changed, 78 insertions(+), 19 deletions(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e27b6bc3f7..f80f072c23 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -119,7 +119,7 @@ jobs: run: pnpm exec playwright install --with-deps - name: Build standalone - run: pnpm --filter client-standalone build + run: TRILIUM_INTEGRATION_TEST=memory pnpm --filter client-standalone build - name: Start standalone preview server run: | diff --git a/apps/client-standalone/package.json b/apps/client-standalone/package.json index 02c6811d48..4396bd1092 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -11,7 +11,7 @@ "start-prod": "pnpm build && pnpm vite preview --port 8888", "coverage": "vitest --coverage", "e2e": "playwright test", - "start-prod-no-dir": "pnpm build && pnpm vite preview --port ${TRILIUM_PORT:-8082} --host 127.0.0.1" + "start-prod-no-dir": "cross-env TRILIUM_INTEGRATION_TEST=memory pnpm build && pnpm vite preview --port ${TRILIUM_PORT:-8082} --host 127.0.0.1" }, "dependencies": { "@excalidraw/excalidraw": "0.18.0", diff --git a/apps/client-standalone/playwright.config.ts b/apps/client-standalone/playwright.config.ts index 196757bfb7..50e15a5ca8 100644 --- a/apps/client-standalone/playwright.config.ts +++ b/apps/client-standalone/playwright.config.ts @@ -3,6 +3,9 @@ import { createBaseConfig } from "../../packages/trilium-e2e/src/base-config"; const port = process.env["TRILIUM_PORT"] ?? "8082"; const baseURL = process.env["BASE_URL"] || `http://127.0.0.1:${port}`; +// Standalone needs the integrationTest query param to load the test fixture DB +process.env["TRILIUM_E2E_QUERY_PARAMS"] = "integrationTest=memory"; + export default createBaseConfig({ appDir: __dirname, projectName: "standalone", diff --git a/apps/client-standalone/src/lightweight/platform_provider.ts b/apps/client-standalone/src/lightweight/platform_provider.ts index 85f3bc18bc..6da914e806 100644 --- a/apps/client-standalone/src/lightweight/platform_provider.ts +++ b/apps/client-standalone/src/lightweight/platform_provider.ts @@ -4,6 +4,7 @@ import type { PlatformProvider } from "@triliumnext/core"; const QUERY_TO_ENV: Record = { "safeMode": "TRILIUM_SAFE_MODE", "startNoteId": "TRILIUM_START_NOTE_ID", + "integrationTest": "TRILIUM_INTEGRATION_TEST", }; export default class StandalonePlatformProvider implements PlatformProvider { diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 283889d7be..647db5384b 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -145,8 +145,21 @@ async function initialize(): Promise { console.log("[Worker] Initializing SQLite WASM..."); await sqlProvider!.initWasm(); - // Try to use OPFS for persistent storage - if (sqlProvider!.isOpfsAvailable()) { + // Check if we're in integration test mode (loaded via ?integrationTest=memory) + const params = new URLSearchParams(queryString); + const integrationTestMode = params.get("integrationTest"); + + if (integrationTestMode === "memory") { + // Load the pre-built test fixture database for e2e tests + console.log("[Worker] Integration test mode: loading fixture database..."); + const response = await fetch("/test-fixtures/document.db"); + if (!response.ok) { + throw new Error(`Failed to fetch test fixture: ${response.status} ${response.statusText}`); + } + const buffer = new Uint8Array(await response.arrayBuffer()); + sqlProvider!.loadFromBuffer(buffer); + } else if (sqlProvider!.isOpfsAvailable()) { + // Try to use OPFS for persistent storage console.log("[Worker] OPFS available, loading persistent database..."); sqlProvider!.loadFromOpfs("/trilium.db"); } else { @@ -256,19 +269,9 @@ async function dispatch(request: LocalRequest) { return appRouter.dispatch(request.method, request.url, request.body, request.headers); } -// Start initialization immediately when the worker loads -console.log("[Worker] Starting initialization..."); -initialize().catch(err => { - console.error("[Worker] Initialization failed:", err); - // Post error to main thread - self.postMessage({ - type: "WORKER_ERROR", - error: { - message: String(err?.message || err), - stack: err?.stack - } - }); -}); +// Wait for the INIT message before initializing so that queryString +// (which may contain ?integrationTest=memory for e2e) is available. +let initReceived = false; self.onmessage = async (event) => { const msg = event.data; @@ -276,6 +279,20 @@ self.onmessage = async (event) => { if (msg.type === "INIT") { queryString = msg.queryString || ""; + if (!initReceived) { + initReceived = true; + console.log("[Worker] Starting initialization..."); + initialize().catch(err => { + console.error("[Worker] Initialization failed:", err); + self.postMessage({ + type: "WORKER_ERROR", + error: { + message: String(err?.message || err), + stack: err?.stack + } + }); + }); + } return; } diff --git a/apps/client-standalone/vite.config.mts b/apps/client-standalone/vite.config.mts index 73b1ff09c6..c53855e2ed 100644 --- a/apps/client-standalone/vite.config.mts +++ b/apps/client-standalone/vite.config.mts @@ -155,6 +155,21 @@ if (!isDev) { ] } +// Include the integration test fixture database for e2e tests +if (process.env.TRILIUM_INTEGRATION_TEST) { + plugins = [ + ...plugins, + viteStaticCopy({ + targets: [ + { + src: "../../../packages/trilium-core/src/test/fixtures/document.db", + dest: "test-fixtures", + } + ] + }) + ] +} + export default defineConfig(() => ({ root: join(__dirname, 'src'), // Set src as root so index.html is served from / envDir: __dirname, // Load .env files from client-standalone directory, not src/ @@ -219,6 +234,12 @@ export default defineConfig(() => ({ "Cross-Origin-Embedder-Policy": "require-corp" } }, + preview: { + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp" + } + }, optimizeDeps: { exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core'] }, diff --git a/packages/trilium-e2e/src/support/app.ts b/packages/trilium-e2e/src/support/app.ts index 665741cee9..8a09db7cdc 100644 --- a/packages/trilium-e2e/src/support/app.ts +++ b/packages/trilium-e2e/src/support/app.ts @@ -1,12 +1,20 @@ import type { BrowserContext } from "@playwright/test"; import { expect, Locator, Page } from "@playwright/test"; -interface GotoOpts { +export interface GotoOpts { url?: string; isMobile?: boolean; preserveTabs?: boolean; } +/** + * Extra query parameters to append to all navigations (e.g. "integrationTest=memory"). + * Set via the TRILIUM_E2E_QUERY_PARAMS environment variable. + */ +function getDefaultQueryParams(): string { + return process.env["TRILIUM_E2E_QUERY_PARAMS"] ?? ""; +} + export function getBaseUrl(): string { const port = process.env["TRILIUM_PORT"] ?? "8082"; return process.env["BASE_URL"] || `http://127.0.0.1:${port}`; @@ -61,10 +69,19 @@ export default class App { url = "/"; } + // Append default query params (e.g. ?integrationTest=memory for standalone) + const extraParams = getDefaultQueryParams(); + if (extraParams) { + const separator = url.includes("?") ? "&" : "?"; + url = `${url}${separator}${extraParams}`; + } + + const isRoot = url === "/" || url.startsWith("/?"); + await this.page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); // Wait for the page to load. - if (url === "/") { + if (isRoot) { await expect(this.page.locator(".tree", { hasText: "Trilium Integration Test" })).toBeVisible(); if (!preserveTabs) { await this.closeAllTabs(); From c0e01becb6d898063ba4a0f26b053accb1b27187 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 17:51:00 +0300 Subject: [PATCH 07/16] e2e(standalone): change startup script --- apps/client-standalone/package.json | 2 +- apps/client-standalone/playwright.config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client-standalone/package.json b/apps/client-standalone/package.json index 4396bd1092..154e831bff 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -11,7 +11,7 @@ "start-prod": "pnpm build && pnpm vite preview --port 8888", "coverage": "vitest --coverage", "e2e": "playwright test", - "start-prod-no-dir": "cross-env TRILIUM_INTEGRATION_TEST=memory pnpm build && pnpm vite preview --port ${TRILIUM_PORT:-8082} --host 127.0.0.1" + "start-prod-no-dir": "cross-env TRILIUM_INTEGRATION_TEST=memory pnpm build && pnpm vite preview --host 127.0.0.1" }, "dependencies": { "@excalidraw/excalidraw": "0.18.0", diff --git a/apps/client-standalone/playwright.config.ts b/apps/client-standalone/playwright.config.ts index 50e15a5ca8..9c2cbe5fec 100644 --- a/apps/client-standalone/playwright.config.ts +++ b/apps/client-standalone/playwright.config.ts @@ -11,7 +11,7 @@ export default createBaseConfig({ projectName: "standalone", workers: 1, webServer: !process.env.TRILIUM_DOCKER ? { - command: "pnpm start-prod-no-dir", + command: `pnpm start-prod-no-dir -- --port ${port}`, url: baseURL, reuseExistingServer: !process.env.CI, cwd: __dirname, From 735c1128e47d837f63629b58946ed0f6a445d95e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 17:55:00 +0300 Subject: [PATCH 08/16] e2e(standalone): change startup script again --- apps/client-standalone/package.json | 2 +- apps/client-standalone/playwright.config.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/client-standalone/package.json b/apps/client-standalone/package.json index 154e831bff..27c96c6b26 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -11,7 +11,7 @@ "start-prod": "pnpm build && pnpm vite preview --port 8888", "coverage": "vitest --coverage", "e2e": "playwright test", - "start-prod-no-dir": "cross-env TRILIUM_INTEGRATION_TEST=memory pnpm build && pnpm vite preview --host 127.0.0.1" + "start-prod-no-dir": "pnpm build && pnpm vite preview --host 127.0.0.1" }, "dependencies": { "@excalidraw/excalidraw": "0.18.0", diff --git a/apps/client-standalone/playwright.config.ts b/apps/client-standalone/playwright.config.ts index 9c2cbe5fec..386c695a79 100644 --- a/apps/client-standalone/playwright.config.ts +++ b/apps/client-standalone/playwright.config.ts @@ -13,6 +13,9 @@ export default createBaseConfig({ webServer: !process.env.TRILIUM_DOCKER ? { command: `pnpm start-prod-no-dir -- --port ${port}`, url: baseURL, + env: { + TRILIUM_INTEGRATION_TEST: "memory" + }, reuseExistingServer: !process.env.CI, cwd: __dirname, timeout: 5 * 60 * 1000 From 4162806288277470d9a95ba4c06fa7e3f32e697c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 17:59:47 +0300 Subject: [PATCH 09/16] e2e(standalone): server not starting due to wrong port --- apps/client-standalone/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client-standalone/playwright.config.ts b/apps/client-standalone/playwright.config.ts index 386c695a79..8c12b4ef04 100644 --- a/apps/client-standalone/playwright.config.ts +++ b/apps/client-standalone/playwright.config.ts @@ -11,7 +11,7 @@ export default createBaseConfig({ projectName: "standalone", workers: 1, webServer: !process.env.TRILIUM_DOCKER ? { - command: `pnpm start-prod-no-dir -- --port ${port}`, + command: `pnpm build && pnpm vite preview --host 127.0.0.1 --port ${port}`, url: baseURL, env: { TRILIUM_INTEGRATION_TEST: "memory" From 2b07e880c7925ac747ce6ff9e229efeae0412cc3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 18:23:13 +0300 Subject: [PATCH 10/16] e2e(standalone): another attempt at getting the database to load --- apps/client-standalone/src/local-bridge.ts | 20 ++++++++++++- .../src/local-server-worker.ts | 30 +++++++++++++++++++ apps/client-standalone/vite.config.mts | 1 + 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/client-standalone/src/local-bridge.ts b/apps/client-standalone/src/local-bridge.ts index dbd7e326d2..df57db2e84 100644 --- a/apps/client-standalone/src/local-bridge.ts +++ b/apps/client-standalone/src/local-bridge.ts @@ -6,10 +6,28 @@ function showFatalErrorDialog(message: string) { alert(message); } +/** + * Collects query params from both `location.search` and the hash's "?..." + * suffix. The SPA uses hash-based routing, so flags like `?integrationTest=memory` + * often end up after the `#` (e.g. `/#root/foo?integrationTest=memory`) and + * are invisible to `location.search`. + */ +function collectQueryString(): string { + const params = new URLSearchParams(location.search); + const hashQueryIndex = location.hash.indexOf("?"); + if (hashQueryIndex >= 0) { + const hashParams = new URLSearchParams(location.hash.substring(hashQueryIndex + 1)); + for (const [key, value] of hashParams) { + if (!params.has(key)) params.set(key, value); + } + } + return params.toString(); +} + export function startLocalServerWorker() { if (localWorker) return localWorker; localWorker = new LocalServerWorker(); - localWorker.postMessage({ type: "INIT", queryString: location.search }); + localWorker.postMessage({ type: "INIT", queryString: collectQueryString() }); // Handle worker errors during initialization localWorker.onerror = (event) => { diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 647db5384b..5ea83dcbec 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -74,6 +74,30 @@ let initPromise: Promise | null = null; let initError: Error | null = null; let queryString = ""; +/** + * Remove every entry from the OPFS root. Used by integration tests to + * guarantee a clean slate — the previous run's DB, logs, and backups would + * otherwise survive and cross-contaminate the next run. + */ +async function clearOpfs(): Promise { + if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) { + return; + } + console.log("[Worker] Clearing OPFS..."); + const root = await navigator.storage.getDirectory(); + const names: string[] = []; + for await (const name of (root as unknown as { keys(): AsyncIterableIterator }).keys()) { + names.push(name); + } + for (const name of names) { + try { + await root.removeEntry(name, { recursive: true }); + } catch (err) { + console.warn(`[Worker] Failed to remove OPFS entry "${name}":`, err); + } + } +} + /** * Load all required modules using dynamic imports. * This allows errors to be caught by our error handlers. @@ -149,7 +173,13 @@ async function initialize(): Promise { const params = new URLSearchParams(queryString); const integrationTestMode = params.get("integrationTest"); + console.log("Starting with integration test mode ", integrationTestMode); + if (integrationTestMode === "memory") { + // Wipe OPFS so e2e runs start from a clean slate (stale DB, logs, + // backups from previous sessions would otherwise leak across runs). + await clearOpfs(); + // Load the pre-built test fixture database for e2e tests console.log("[Worker] Integration test mode: loading fixture database..."); const response = await fetch("/test-fixtures/document.db"); diff --git a/apps/client-standalone/vite.config.mts b/apps/client-standalone/vite.config.mts index c53855e2ed..af90d334a8 100644 --- a/apps/client-standalone/vite.config.mts +++ b/apps/client-standalone/vite.config.mts @@ -164,6 +164,7 @@ if (process.env.TRILIUM_INTEGRATION_TEST) { { src: "../../../packages/trilium-core/src/test/fixtures/document.db", dest: "test-fixtures", + rename: "document.db" } ] }) From f44a1f690a0ef03944f4a21f1ec66f6db865eb27 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 18:24:25 +0300 Subject: [PATCH 11/16] e2e(standalone): use different mechanism for handling integration test --- apps/client-standalone/playwright.config.ts | 3 --- .../src/lightweight/platform_provider.ts | 7 ++++++- apps/client-standalone/src/local-bridge.ts | 20 +------------------ .../src/local-server-worker.ts | 12 ++++++----- apps/client-standalone/vite.config.mts | 1 + packages/trilium-e2e/src/support/app.ts | 19 +----------------- 6 files changed, 16 insertions(+), 46 deletions(-) diff --git a/apps/client-standalone/playwright.config.ts b/apps/client-standalone/playwright.config.ts index 8c12b4ef04..c93c99df9d 100644 --- a/apps/client-standalone/playwright.config.ts +++ b/apps/client-standalone/playwright.config.ts @@ -3,9 +3,6 @@ import { createBaseConfig } from "../../packages/trilium-e2e/src/base-config"; const port = process.env["TRILIUM_PORT"] ?? "8082"; const baseURL = process.env["BASE_URL"] || `http://127.0.0.1:${port}`; -// Standalone needs the integrationTest query param to load the test fixture DB -process.env["TRILIUM_E2E_QUERY_PARAMS"] = "integrationTest=memory"; - export default createBaseConfig({ appDir: __dirname, projectName: "standalone", diff --git a/apps/client-standalone/src/lightweight/platform_provider.ts b/apps/client-standalone/src/lightweight/platform_provider.ts index 6da914e806..9c5e2f1186 100644 --- a/apps/client-standalone/src/lightweight/platform_provider.ts +++ b/apps/client-standalone/src/lightweight/platform_provider.ts @@ -1,10 +1,12 @@ import type { PlatformProvider } from "@triliumnext/core"; +// Build-time constant injected by Vite (see `define` in vite.config.mts). +declare const __TRILIUM_INTEGRATION_TEST__: string; + /** Maps URL query parameter names to TRILIUM_ environment variable names. */ const QUERY_TO_ENV: Record = { "safeMode": "TRILIUM_SAFE_MODE", "startNoteId": "TRILIUM_START_NOTE_ID", - "integrationTest": "TRILIUM_INTEGRATION_TEST", }; export default class StandalonePlatformProvider implements PlatformProvider { @@ -21,6 +23,9 @@ export default class StandalonePlatformProvider implements PlatformProvider { this.envMap[envKey] = params.get(queryKey) || "true"; } } + if (__TRILIUM_INTEGRATION_TEST__) { + this.envMap["TRILIUM_INTEGRATION_TEST"] = __TRILIUM_INTEGRATION_TEST__; + } } crash(message: string): void { diff --git a/apps/client-standalone/src/local-bridge.ts b/apps/client-standalone/src/local-bridge.ts index df57db2e84..dbd7e326d2 100644 --- a/apps/client-standalone/src/local-bridge.ts +++ b/apps/client-standalone/src/local-bridge.ts @@ -6,28 +6,10 @@ function showFatalErrorDialog(message: string) { alert(message); } -/** - * Collects query params from both `location.search` and the hash's "?..." - * suffix. The SPA uses hash-based routing, so flags like `?integrationTest=memory` - * often end up after the `#` (e.g. `/#root/foo?integrationTest=memory`) and - * are invisible to `location.search`. - */ -function collectQueryString(): string { - const params = new URLSearchParams(location.search); - const hashQueryIndex = location.hash.indexOf("?"); - if (hashQueryIndex >= 0) { - const hashParams = new URLSearchParams(location.hash.substring(hashQueryIndex + 1)); - for (const [key, value] of hashParams) { - if (!params.has(key)) params.set(key, value); - } - } - return params.toString(); -} - export function startLocalServerWorker() { if (localWorker) return localWorker; localWorker = new LocalServerWorker(); - localWorker.postMessage({ type: "INIT", queryString: collectQueryString() }); + localWorker.postMessage({ type: "INIT", queryString: location.search }); // Handle worker errors during initialization localWorker.onerror = (event) => { diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 5ea83dcbec..420cb46a7f 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -48,6 +48,9 @@ console.log("[Worker] Error handlers installed, loading modules..."); // ============================================================================= import type { BrowserRouter } from './lightweight/browser_router'; +// Build-time constant injected by Vite (see `define` in vite.config.mts). +declare const __TRILIUM_INTEGRATION_TEST__: string; + // ============================================================================= // MODULE STATE (populated by dynamic imports) // ============================================================================= @@ -169,11 +172,10 @@ async function initialize(): Promise { console.log("[Worker] Initializing SQLite WASM..."); await sqlProvider!.initWasm(); - // Check if we're in integration test mode (loaded via ?integrationTest=memory) - const params = new URLSearchParams(queryString); - const integrationTestMode = params.get("integrationTest"); - - console.log("Starting with integration test mode ", integrationTestMode); + // Integration test mode is baked in at build time via the + // __TRILIUM_INTEGRATION_TEST__ Vite define (derived from the + // TRILIUM_INTEGRATION_TEST env var when the bundle was built). + const integrationTestMode = __TRILIUM_INTEGRATION_TEST__; if (integrationTestMode === "memory") { // Wipe OPFS so e2e runs start from a clean slate (stale DB, logs, diff --git a/apps/client-standalone/vite.config.mts b/apps/client-standalone/vite.config.mts index af90d334a8..0fa1dfbe96 100644 --- a/apps/client-standalone/vite.config.mts +++ b/apps/client-standalone/vite.config.mts @@ -298,5 +298,6 @@ export default defineConfig(() => ({ }, define: { "process.env.IS_PREACT": JSON.stringify("true"), + __TRILIUM_INTEGRATION_TEST__: JSON.stringify(process.env.TRILIUM_INTEGRATION_TEST ?? ""), } })); \ No newline at end of file diff --git a/packages/trilium-e2e/src/support/app.ts b/packages/trilium-e2e/src/support/app.ts index 8a09db7cdc..230721a3ab 100644 --- a/packages/trilium-e2e/src/support/app.ts +++ b/packages/trilium-e2e/src/support/app.ts @@ -7,14 +7,6 @@ export interface GotoOpts { preserveTabs?: boolean; } -/** - * Extra query parameters to append to all navigations (e.g. "integrationTest=memory"). - * Set via the TRILIUM_E2E_QUERY_PARAMS environment variable. - */ -function getDefaultQueryParams(): string { - return process.env["TRILIUM_E2E_QUERY_PARAMS"] ?? ""; -} - export function getBaseUrl(): string { const port = process.env["TRILIUM_PORT"] ?? "8082"; return process.env["BASE_URL"] || `http://127.0.0.1:${port}`; @@ -69,19 +61,10 @@ export default class App { url = "/"; } - // Append default query params (e.g. ?integrationTest=memory for standalone) - const extraParams = getDefaultQueryParams(); - if (extraParams) { - const separator = url.includes("?") ? "&" : "?"; - url = `${url}${separator}${extraParams}`; - } - - const isRoot = url === "/" || url.startsWith("/?"); - await this.page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); // Wait for the page to load. - if (isRoot) { + if (url === "/") { await expect(this.page.locator(".tree", { hasText: "Trilium Integration Test" })).toBeVisible(); if (!preserveTabs) { await this.closeAllTabs(); From c3b1cfd7a5d01717f0139578c9d32d1c1deb50f4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 18:30:13 +0300 Subject: [PATCH 12/16] e2e(standalone): get database to load --- apps/client-standalone/src/local-server-worker.ts | 11 +++++++++++ apps/client-standalone/vite.config.mts | 9 +++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 420cb46a7f..3418beea02 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -189,6 +189,17 @@ async function initialize(): Promise { throw new Error(`Failed to fetch test fixture: ${response.status} ${response.statusText}`); } const buffer = new Uint8Array(await response.arrayBuffer()); + // Verify we actually got a SQLite database, not an SPA-fallback + // index.html served for a missing file. SQLite databases start + // with the 16-byte magic string "SQLite format 3\0". + const magic = new TextDecoder().decode(buffer.subarray(0, 15)); + if (magic !== "SQLite format 3") { + throw new Error( + `Test fixture at /test-fixtures/document.db is not a SQLite database ` + + `(got ${buffer.byteLength} bytes starting with "${magic}"). ` + + `The file is likely missing and the SPA fallback is returning index.html.` + ); + } sqlProvider!.loadFromBuffer(buffer); } else if (sqlProvider!.isOpfsAvailable()) { // Try to use OPFS for persistent storage diff --git a/apps/client-standalone/vite.config.mts b/apps/client-standalone/vite.config.mts index 0fa1dfbe96..cb38be1e81 100644 --- a/apps/client-standalone/vite.config.mts +++ b/apps/client-standalone/vite.config.mts @@ -162,9 +162,14 @@ if (process.env.TRILIUM_INTEGRATION_TEST) { viteStaticCopy({ targets: [ { - src: "../../../packages/trilium-core/src/test/fixtures/document.db", + // Forward slashes are required because fast-glob (used + // internally) treats backslashes as escape characters on + // Windows. `stripBase` drops the source's directory + // structure so the file lands flat at `test-fixtures/document.db` + // rather than mirroring the `packages/trilium-core/...` path. + src: join(__dirname, "../../packages/trilium-core/src/test/fixtures/document.db").replace(/\\/g, "/"), dest: "test-fixtures", - rename: "document.db" + rename: { stripBase: true } } ] }) From 1e4c6eb12c49bc978d2089c3c37c62fde6b5c0fc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 18:39:57 +0300 Subject: [PATCH 13/16] e2e(standalone): use different seeding mechanism --- .../src/local-server-worker.ts | 101 +++++++++++------- 1 file changed, 64 insertions(+), 37 deletions(-) diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 3418beea02..77092cff65 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -78,26 +78,47 @@ let initError: Error | null = null; let queryString = ""; /** - * Remove every entry from the OPFS root. Used by integration tests to - * guarantee a clean slate — the previous run's DB, logs, and backups would - * otherwise survive and cross-contaminate the next run. + * Check whether a file exists at the OPFS root. Used to decide whether the + * test fixture needs to be seeded or whether we should reuse the existing + * DB (preserving changes made earlier in the same test — e.g. options set + * before a page reload). */ -async function clearOpfs(): Promise { +async function opfsFileExists(fileName: string): Promise { if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) { - return; + return false; } - console.log("[Worker] Clearing OPFS..."); const root = await navigator.storage.getDirectory(); - const names: string[] = []; - for await (const name of (root as unknown as { keys(): AsyncIterableIterator }).keys()) { - names.push(name); + try { + await root.getFileHandle(fileName); + return true; + } catch { + return false; } - for (const name of names) { - try { - await root.removeEntry(name, { recursive: true }); - } catch (err) { - console.warn(`[Worker] Failed to remove OPFS entry "${name}":`, err); - } +} + +/** + * Write a raw byte buffer to an OPFS file. Used to drop the test fixture DB + * into OPFS as a regular file so SQLite's OPFS VFS can then open it. Requires + * a Worker context (`createSyncAccessHandle` isn't available on the main thread + * in some browsers). + */ +async function writeOpfsFile(fileName: string, buffer: Uint8Array): Promise { + const root = await navigator.storage.getDirectory(); + const fileHandle = await root.getFileHandle(fileName, { create: true }); + const accessHandle = await (fileHandle as unknown as { + createSyncAccessHandle(): Promise<{ + truncate(size: number): void; + write(buffer: Uint8Array, opts: { at: number }): number; + flush(): void; + close(): void; + }>; + }).createSyncAccessHandle(); + try { + accessHandle.truncate(0); + accessHandle.write(buffer, { at: 0 }); + accessHandle.flush(); + } finally { + accessHandle.close(); } } @@ -178,29 +199,35 @@ async function initialize(): Promise { const integrationTestMode = __TRILIUM_INTEGRATION_TEST__; if (integrationTestMode === "memory") { - // Wipe OPFS so e2e runs start from a clean slate (stale DB, logs, - // backups from previous sessions would otherwise leak across runs). - await clearOpfs(); - - // Load the pre-built test fixture database for e2e tests - console.log("[Worker] Integration test mode: loading fixture database..."); - const response = await fetch("/test-fixtures/document.db"); - if (!response.ok) { - throw new Error(`Failed to fetch test fixture: ${response.status} ${response.statusText}`); + // Use OPFS for the DB in integration test mode so option changes + // (and any other writes) survive page reloads within a single test. + // Playwright gives each test a fresh BrowserContext, which means a + // fresh OPFS — so on the first worker init of a test we seed from + // the fixture, and subsequent inits in the same test reuse it. + const opfsDbName = "trilium.db"; + if (!(await opfsFileExists(opfsDbName))) { + console.log("[Worker] Integration test mode: seeding fixture database into OPFS..."); + const response = await fetch("/test-fixtures/document.db"); + if (!response.ok) { + throw new Error(`Failed to fetch test fixture: ${response.status} ${response.statusText}`); + } + const buffer = new Uint8Array(await response.arrayBuffer()); + // Verify we actually got a SQLite database, not an SPA-fallback + // index.html served for a missing file. SQLite databases start + // with the 16-byte magic string "SQLite format 3\0". + const magic = new TextDecoder().decode(buffer.subarray(0, 15)); + if (magic !== "SQLite format 3") { + throw new Error( + `Test fixture at /test-fixtures/document.db is not a SQLite database ` + + `(got ${buffer.byteLength} bytes starting with "${magic}"). ` + + `The file is likely missing and the SPA fallback is returning index.html.` + ); + } + await writeOpfsFile(opfsDbName, buffer); + } else { + console.log("[Worker] Integration test mode: reusing existing OPFS DB from earlier in this test"); } - const buffer = new Uint8Array(await response.arrayBuffer()); - // Verify we actually got a SQLite database, not an SPA-fallback - // index.html served for a missing file. SQLite databases start - // with the 16-byte magic string "SQLite format 3\0". - const magic = new TextDecoder().decode(buffer.subarray(0, 15)); - if (magic !== "SQLite format 3") { - throw new Error( - `Test fixture at /test-fixtures/document.db is not a SQLite database ` + - `(got ${buffer.byteLength} bytes starting with "${magic}"). ` + - `The file is likely missing and the SPA fallback is returning index.html.` - ); - } - sqlProvider!.loadFromBuffer(buffer); + sqlProvider!.loadFromOpfs(`/${opfsDbName}`); } else if (sqlProvider!.isOpfsAvailable()) { // Try to use OPFS for persistent storage console.log("[Worker] OPFS available, loading persistent database..."); From 7fd50a2c6ecea35c0aeaa7aa245525666529c13a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 18:42:57 +0300 Subject: [PATCH 14/16] e2e(standalone): settings change not intercepted --- packages/trilium-e2e/src/support/app.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/trilium-e2e/src/support/app.ts b/packages/trilium-e2e/src/support/app.ts index 230721a3ab..d2d260407a 100644 --- a/packages/trilium-e2e/src/support/app.ts +++ b/packages/trilium-e2e/src/support/app.ts @@ -162,18 +162,25 @@ export default class App { } async setOption(key: string, value: string) { - const csrfToken = await this.page.evaluate(() => { - return (window as any).glob.csrfToken; - }); - - expect(csrfToken).toBeTruthy(); - await expect( - await this.page.request.put(`${getBaseUrl()}/api/options/${key}/${value}`, { + // Issue the request from inside the page so standalone's service worker + // intercepts it and routes to the local SQLite worker. Playwright's own + // request client (page.request.*) bypasses the page entirely, which in + // standalone mode just hits the vite preview server and gets 404. + const result = await this.page.evaluate(async ({ key, value }) => { + const csrfToken = (window as any).glob.csrfToken; + if (!csrfToken) { + return { ok: false, status: 0, error: "missing csrfToken" }; + } + const response = await fetch(`/api/options/${encodeURIComponent(key)}/${encodeURIComponent(value)}`, { + method: "PUT", headers: { "x-csrf-token": csrfToken } - }) - ).toBeOK(); + }); + return { ok: response.ok, status: response.status }; + }, { key, value }); + + expect(result.ok, `PUT /api/options/${key}/${value} failed (status=${result.status})`).toBe(true); } dropdown(_locator: Locator): DropdownLocator { From 24d0b3f6b1b59759f93ce8167ab9b48b25e09e0b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 19:14:29 +0300 Subject: [PATCH 15/16] e2e(standalone): test failing due to reload mechanism --- packages/trilium-e2e/src/support/app.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/trilium-e2e/src/support/app.ts b/packages/trilium-e2e/src/support/app.ts index d2d260407a..b8f4215af2 100644 --- a/packages/trilium-e2e/src/support/app.ts +++ b/packages/trilium-e2e/src/support/app.ts @@ -61,7 +61,19 @@ export default class App { url = "/"; } - await this.page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + // If we're already on the target (modulo hash), page.goto treats it as + // a same-document navigation and doesn't reload. In standalone that + // means the worker keeps its current state — so option changes made + // since the last navigation (e.g. a locale switch via setOption) won't + // take effect. Force a real reload in that case. + const currentUrl = this.page.url(); + const targetUrl = new URL(url, getBaseUrl()).toString(); + const stripHash = (u: string) => u.split("#")[0]; + if (currentUrl !== "about:blank" && stripHash(currentUrl) === stripHash(targetUrl)) { + await this.page.reload({ waitUntil: "networkidle", timeout: 30_000 }); + } else { + await this.page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + } // Wait for the page to load. if (url === "/") { From fb85af44e256bbe4d8fff5bea63c99de4a775312 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 19 Apr 2026 19:30:01 +0300 Subject: [PATCH 16/16] fix(standalone): change locale after DB init --- apps/client-standalone/src/local-server-worker.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 77092cff65..6431b0354f 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -294,6 +294,20 @@ async function initialize(): Promise { if (coreModule.sql_init.isDbInitialized()) { console.log("[Worker] Database already initialized, loading becca..."); await coreModule.becca_loader.beccaLoaded; + + // `initTranslations` runs before `initSql` inside `initializeCore` + // (options_init needs translations, creating a chicken-and-egg), + // so it always defaults to "en" on a fresh worker boot. Now that + // the DB is up we can read the real locale and, if it differs, + // switch i18next and rebuild the hidden subtree with the correct + // titles. This must happen BEFORE `startScheduler` registers its + // own `dbReady.then(checkHiddenSubtree)` so the scheduled rebuild + // sees the right language. + const dbLocale = coreModule.options.getOptionOrNull("locale"); + if (dbLocale && dbLocale !== "en") { + console.log(`[Worker] Reconciling i18next locale to "${dbLocale}" from DB`); + await coreModule.i18n.changeLanguage(dbLocale); + } } else { console.log("[Worker] Database not initialized, skipping becca load (will be loaded during DB initialization)"); }