diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 07e4cf819b..73811b9cb1 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -65,13 +65,20 @@ jobs: path: apps/server/test-output/vitest/html/ retention-days: 30 + - name: Run the client-standalone tests + # Runs the same trilium-core spec set as the server suite, but in + # happy-dom + sql.js WASM via BrowserSqlProvider (see + # apps/client-standalone/src/test_setup.ts). Catches differences + # between the Node-side and browser-side runtimes. + run: pnpm run --filter=client-standalone test + - name: Run CKEditor e2e tests run: | pnpm run --filter=ckeditor5-mermaid test pnpm run --filter=ckeditor5-math test - name: Run the rest of the tests - run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test + run: pnpm run --filter=\!client --filter=\!client-standalone --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test build_docker: name: Build Docker image diff --git a/apps/client-standalone/src/lightweight/sql_provider.ts b/apps/client-standalone/src/lightweight/sql_provider.ts index 4b22833de4..972b58db0d 100644 --- a/apps/client-standalone/src/lightweight/sql_provider.ts +++ b/apps/client-standalone/src/lightweight/sql_provider.ts @@ -435,9 +435,18 @@ export default class BrowserSqlProvider implements DatabaseProvider { loadFromBuffer(buffer: Uint8Array): void { this.ensureSqlite3(); - // SQLite WASM can deserialize a database from a byte array - const p = this.sqlite3!.wasm.allocFromTypedArray(buffer); + // SQLite WASM's allocFromTypedArray rejects Node's Buffer (and other + // non-Uint8Array typed arrays) with "expecting 8/16/32/64". Normalize + // to a plain Uint8Array view over the same memory so callers can pass + // anything readFileSync returns. + const view = buffer instanceof Uint8Array && buffer.constructor === Uint8Array + ? buffer + : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const p = this.sqlite3!.wasm.allocFromTypedArray(view); try { + // Cached statements reference the previous DB and become invalid + // once we swap connections. Drop them so callers re-prepare. + this.clearStatementCache(); this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" }); this.opfsDbPath = undefined; // Not using OPFS @@ -445,8 +454,8 @@ export default class BrowserSqlProvider implements DatabaseProvider { this.db.pointer!, "main", p, - buffer.byteLength, - buffer.byteLength, + view.byteLength, + view.byteLength, this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE | this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE ); @@ -563,8 +572,7 @@ export default class BrowserSqlProvider implements DatabaseProvider { this.db!.exec(query); } - close(): void { - // Clean up all cached statements first + private clearStatementCache(): void { for (const statement of this.statementCache.values()) { try { statement.finalize(); @@ -574,6 +582,10 @@ export default class BrowserSqlProvider implements DatabaseProvider { } } this.statementCache.clear(); + } + + close(): void { + this.clearStatementCache(); if (this.db) { this.db.close(); diff --git a/apps/client-standalone/src/lightweight/zip_provider.ts b/apps/client-standalone/src/lightweight/zip_provider.ts index 5827ebcdae..f5e435c5c5 100644 --- a/apps/client-standalone/src/lightweight/zip_provider.ts +++ b/apps/client-standalone/src/lightweight/zip_provider.ts @@ -65,7 +65,7 @@ export default class BrowserZipProvider implements ZipProvider { try { for (const [fileName, data] of Object.entries(files)) { await processEntry( - { fileName }, + { fileName: decodeZipFileName(fileName) }, () => Promise.resolve(data) ); } @@ -77,3 +77,25 @@ export default class BrowserZipProvider implements ZipProvider { }); } } + +const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +/** + * fflate decodes ZIP entry filenames as CP437/Latin-1 unless the language + * encoding flag (general purpose bit 11) is set, but many real-world archives + * (e.g. those produced by macOS / Linux unzip / Python's zipfile) write UTF-8 + * filenames without setting that flag. Recover the original UTF-8 bytes from + * fflate's per-byte string and re-decode them; if the result isn't valid + * UTF-8 we fall back to the as-decoded name. + */ +function decodeZipFileName(name: string): string { + const bytes = new Uint8Array(name.length); + for (let i = 0; i < name.length; i++) { + bytes[i] = name.charCodeAt(i) & 0xff; + } + try { + return utf8Decoder.decode(bytes); + } catch { + return name; + } +} diff --git a/apps/client-standalone/src/test_setup.ts b/apps/client-standalone/src/test_setup.ts new file mode 100644 index 0000000000..cd77c0a416 --- /dev/null +++ b/apps/client-standalone/src/test_setup.ts @@ -0,0 +1,140 @@ +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { initializeCore } from "@triliumnext/core"; +import schemaSql from "@triliumnext/core/src/assets/schema.sql?raw"; +import HappyDomHtmlParser from "happy-dom/lib/html-parser/HTMLParser.js"; +import serverEnTranslations from "../../server/src/assets/translations/en/server.json"; +import { beforeAll } from "vitest"; + +import BrowserExecutionContext from "./lightweight/cls_provider.js"; +import BrowserCryptoProvider from "./lightweight/crypto_provider.js"; +import StandalonePlatformProvider from "./lightweight/platform_provider.js"; +import BrowserSqlProvider from "./lightweight/sql_provider.js"; +import BrowserZipProvider from "./lightweight/zip_provider.js"; + +// ============================================================================= +// SQLite WASM compatibility shims +// ============================================================================= +// The @sqlite.org/sqlite-wasm package loads its .wasm via fetch, and its +// bundled `instantiateWasm` hook overrides any user-supplied alternative. +// Two things go wrong under vitest + happy-dom: +// 1. happy-dom's `fetch()` refuses `file://` URLs. +// 2. happy-dom installs its own Response global, which Node's +// `WebAssembly.instantiateStreaming` rejects ("Received an instance of +// Response" — it wants undici's Response). +// We intercept fetch for file:// URLs ourselves and force instantiateStreaming +// to fall back to the ArrayBuffer path. +const fileFetchCache = new Map(); + +function readFileAsArrayBuffer(url: string): ArrayBuffer { + let cached = fileFetchCache.get(url); + if (!cached) { + const bytes = readFileSync(fileURLToPath(url)); + cached = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + fileFetchCache.set(url, cached); + } + return cached; +} + +const originalFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + + if (url.startsWith("file://")) { + const body = readFileAsArrayBuffer(url); + return new Response(body, { + status: 200, + headers: { "Content-Type": "application/wasm" } + }); + } + + return originalFetch(input as RequestInfo, init); +}) as typeof fetch; + +WebAssembly.instantiateStreaming = (async (source, importObject) => { + const response = await source; + const bytes = await response.arrayBuffer(); + return WebAssembly.instantiate(bytes, importObject); +}) as typeof WebAssembly.instantiateStreaming; + +// ============================================================================= +// happy-dom HTMLParser spec compliance patch +// ============================================================================= +// Per HTML5 parsing spec, a single U+000A LINE FEED immediately after a
,
+// , or