mirror of
https://github.com/zadam/trilium.git
synced 2026-05-06 10:46:57 +02:00
e2e: make tests reusable for standalone (#9491)
This commit is contained in:
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -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`).
|
||||
|
||||
2
.github/workflows/main-docker.yml
vendored
2
.github/workflows/main-docker.yml
vendored
@@ -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()
|
||||
|
||||
57
.github/workflows/playwright.yml
vendored
57
.github/workflows/playwright.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
20
apps/client-standalone/playwright.config.ts
Normal file
20
apps/client-standalone/playwright.config.ts
Normal file
@@ -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,
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
"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 {
|
||||
|
||||
@@ -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<void> | 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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? ""),
|
||||
}
|
||||
}));
|
||||
@@ -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"] },
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
22
apps/server/playwright.config.ts
Normal file
22
apps/server/playwright.config.ts
Normal file
@@ -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,
|
||||
});
|
||||
10
docs/Developer Guide/Developer Guide/Testing.md
vendored
10
docs/Developer Guide/Developer Guide/Testing.md
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
"apps/dump-db"
|
||||
"apps/edit-docs"
|
||||
"apps/server"
|
||||
"apps/server-e2e"
|
||||
"packages/trilium-e2e"
|
||||
];
|
||||
|
||||
desktopItems = lib.optionals (app == "desktop") [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
69
packages/trilium-e2e/src/base-config.ts
Normal file
69
packages/trilium-e2e/src/base-config.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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" });
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<void>;
|
||||
@@ -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 {
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"path": "./apps/server"
|
||||
},
|
||||
{
|
||||
"path": "./apps/server-e2e"
|
||||
"path": "./packages/trilium-e2e"
|
||||
},
|
||||
{
|
||||
"path": "./apps/client"
|
||||
|
||||
Reference in New Issue
Block a user