e2e: make tests reusable for standalone (#9491)

This commit is contained in:
Elian Doran
2026-04-19 19:39:39 +03:00
committed by GitHub
39 changed files with 390 additions and 105 deletions

View File

@@ -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`).

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View 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,
});

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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 ?? ""),
}
}));

View File

@@ -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"] },
}
]
});

View File

@@ -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.

View File

@@ -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);

View File

@@ -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",

View 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,
});

View File

@@ -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

View File

@@ -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

View File

@@ -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 },

View File

@@ -177,7 +177,7 @@
"apps/dump-db"
"apps/edit-docs"
"apps/server"
"apps/server-e2e"
"packages/trilium-e2e"
];
desktopItems = lib.optionals (app == "desktop") [

View File

@@ -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"
}

View 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,
});
}

View File

@@ -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" });

View File

@@ -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();
});

View File

@@ -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
View File

@@ -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:

View File

@@ -13,7 +13,7 @@
"path": "./apps/server"
},
{
"path": "./apps/server-e2e"
"path": "./packages/trilium-e2e"
},
{
"path": "./apps/client"