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..f80f072c23 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: @@ -73,15 +73,66 @@ 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() 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 }} + env: + TRILIUM_DOCKER: 1 + TRILIUM_PORT: 8082 + 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: Build standalone + run: TRILIUM_INTEGRATION_TEST=memory pnpm --filter client-standalone build + + - name: Start standalone preview server + run: | + cd apps/client-standalone + pnpm vite preview --port $TRILIUM_PORT --host 127.0.0.1 & + sleep 5 + + - 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 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..27c96c6b26 100644 --- a/apps/client-standalone/package.json +++ b/apps/client-standalone/package.json @@ -9,7 +9,9 @@ "dev": "vite dev", "test": "vitest", "start-prod": "pnpm build && pnpm vite preview --port 8888", - "coverage": "vitest --coverage" + "coverage": "vitest --coverage", + "e2e": "playwright test", + "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 new file mode 100644 index 0000000000..c93c99df9d --- /dev/null +++ b/apps/client-standalone/playwright.config.ts @@ -0,0 +1,20 @@ +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", + workers: 1, + webServer: !process.env.TRILIUM_DOCKER ? { + command: `pnpm build && pnpm vite preview --host 127.0.0.1 --port ${port}`, + url: baseURL, + env: { + TRILIUM_INTEGRATION_TEST: "memory" + }, + reuseExistingServer: !process.env.CI, + cwd: __dirname, + timeout: 5 * 60 * 1000 + } : undefined, +}); diff --git a/apps/client-standalone/src/lightweight/platform_provider.ts b/apps/client-standalone/src/lightweight/platform_provider.ts index 85f3bc18bc..9c5e2f1186 100644 --- a/apps/client-standalone/src/lightweight/platform_provider.ts +++ b/apps/client-standalone/src/lightweight/platform_provider.ts @@ -1,5 +1,8 @@ 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", @@ -20,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-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 283889d7be..6431b0354f 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) // ============================================================================= @@ -74,6 +77,51 @@ let initPromise: Promise | null = null; let initError: Error | null = null; let queryString = ""; +/** + * 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 opfsFileExists(fileName: string): Promise { + if (typeof navigator === "undefined" || !navigator.storage?.getDirectory) { + return false; + } + const root = await navigator.storage.getDirectory(); + try { + await root.getFileHandle(fileName); + return true; + } catch { + return false; + } +} + +/** + * 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(); + } +} + /** * Load all required modules using dynamic imports. * This allows errors to be caught by our error handlers. @@ -145,8 +193,43 @@ async function initialize(): Promise { console.log("[Worker] Initializing SQLite WASM..."); await sqlProvider!.initWasm(); - // Try to use OPFS for persistent storage - if (sqlProvider!.isOpfsAvailable()) { + // 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") { + // 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"); + } + sqlProvider!.loadFromOpfs(`/${opfsDbName}`); + } else if (sqlProvider!.isOpfsAvailable()) { + // Try to use OPFS for persistent storage console.log("[Worker] OPFS available, loading persistent database..."); sqlProvider!.loadFromOpfs("/trilium.db"); } else { @@ -211,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)"); } @@ -256,19 +353,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 +363,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..cb38be1e81 100644 --- a/apps/client-standalone/vite.config.mts +++ b/apps/client-standalone/vite.config.mts @@ -155,6 +155,27 @@ if (!isDev) { ] } +// Include the integration test fixture database for e2e tests +if (process.env.TRILIUM_INTEGRATION_TEST) { + plugins = [ + ...plugins, + viteStaticCopy({ + targets: [ + { + // 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: { stripBase: true } + } + ] + }) + ] +} + 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 +240,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'] }, @@ -276,5 +303,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/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..321916dff8 --- /dev/null +++ b/packages/trilium-e2e/src/base-config.ts @@ -0,0 +1,69 @@ +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"]; + + /** + * 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, 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); + + 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", + }, + workers, + 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 75% rename from apps/server-e2e/src/support/app.ts rename to packages/trilium-e2e/src/support/app.ts index 3922019192..b8f4215af2 100644 --- a/apps/server-e2e/src/support/app.ts +++ b/packages/trilium-e2e/src/support/app.ts @@ -1,13 +1,16 @@ import type { BrowserContext } from "@playwright/test"; import { expect, Locator, Page } from "@playwright/test"; -interface GotoOpts { +export interface GotoOpts { url?: string; isMobile?: boolean; 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" } @@ -58,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 === "/") { @@ -159,18 +174,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(`${BASE_URL}/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 { 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"